Make your apex/content pages SEO/AEO/GEO discoverable
The seo feature reference lists the available
fields. This guide covers the setup, the public routes, page-head data, and
the checks that show what a crawler actually receives.
Prerequisites
Section titled “Prerequisites”configfeature mounted (a hard requirement ofseo).- A marketing/apex page rendered via
renderApexPage(ApexHead) or a CMS page viawrapInLayout(legal-pages/managed-pages). If neither exists yet,seostill works,sitemapEntries()accepts any list of URLs your app wants indexed. - A production host or preview host to use for the HTTP checks in step 4.
The seo routes are public. Only put URLs and metadata in them that you are
prepared to publish. Keep the callback app-authored; do not build it from
unreviewed user input.
1. Mount the feature
Section titled “1. Mount the feature”import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
createSeoFeature({ sitemapEntries: (host) => [{ loc: `https://${host}/` }], includeLegalPages: true, // only when legal-pages is mounted managedPages: { resolveApexTenant }, // only when managed-pages is mounted robotsPolicy: (host) => ({ allow: true, sitemapUrl: `https://${host}/sitemap.xml` }),});sitemapEntries is the app-owned list of public pages. The callback must return
fully-qualified URLs. includeLegalPages and managedPages add routes from
those features; enable them only when the corresponding feature is mounted.
robotsPolicy is optional. Without it, the feature does not register a runtime
/robots.txt route, so a static public/robots.txt remains in charge.
For a preview host, return { allow: false } from robotsPolicy (or keep the
static file) so the preview is not advertised to crawlers. aiCrawlers: true
adds explicit Allow rules for the supported AI crawlers; it does not make
content public by itself.
2. Set the config values
Section titled “2. Set the config values”Five tenant-scoped config keys feed the llms.txt title/summary and the
metadata query: seo-organization-name,
seo-organization-logo-url, seo-twitter-site, seo-llms-summary,
seo-default-og-image. They default to empty strings. The seo routes do not
automatically inject JSON-LD or page-head tags: read these values in the app’s
page builder and pass them to ApexHead or wrapInLayout.
For a single-brand app, app-wide overrides are useful. The runtime auto-wires
the environment-to-config bridge in auth mode; an app-supplied
configResolver replaces that default, so keep the override in the resolver
you actually pass to runProdApp/runDevApp:
import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";import { SEO_CONFIG_QN } from "@cosmicdrift/kumiko-bundled-features/seo";
const configResolver = createConfigResolver({ appOverrides: new Map([ [SEO_CONFIG_QN.organizationName, "Your Product"], [SEO_CONFIG_QN.twitterSite, "@yourhandle"], [ SEO_CONFIG_QN.llmsSummary, "One or two sentences: what the product does and who it's for. " + "This is the line an LLM answer engine quotes back, write it " + "like a pitch, not a tagline.", ], ]),});
await runProdApp({ features: appFeatures, auth: authOptions, extraContext: { configResolver },});The override is a fallback. A tenant row wins over an app override, so do not use this map to bypass tenant-specific branding.
For a multi-tenant app where each tenant has its own brand, omit the app
override and use the normal config write path. These are regular
createTenantConfig keys and their write rule is system plus admin access.
Keep seo-llms-summary factual and short. It is inserted into llms.txt; a
product name alone is not a useful summary.
3. Feed it real JSON-LD
Section titled “3. Feed it real JSON-LD”JSON-LD is a JSON object in a <script type="application/ld+json"> block in
the page head. @context selects schema.org, @type selects the object type,
and @graph groups multiple nodes.
The feature exports three pure builder functions. Pass their output to
ApexHead.schemaJson (apex pages) or
wrapInLayout({ seo: { schemaJson } }) (CMS pages):
import { organizationSchema, webPageSchema } from "@cosmicdrift/kumiko-bundled-features/seo";
const head: ApexHead = { // ...title, description, canonicalUrl, etc. schemaJson: { "@context": "https://schema.org", "@graph": [ organizationSchema({ name: "Your Product", url: "https://example.com/" }), webPageSchema({ name: pageTitle, url: canonicalUrl, description: pageDescription, }), ], },};If the page has a real FAQ section, use faqPageSchema(items). Do not invent
questions just to add structured data.
For another type such as Product, Article, or BreadcrumbList, create the
node directly in schemaJson. The property accepts a plain object. Check the
fields against
schema.org’s own type list and drop it
into the same @graph array:
schemaJson: { "@context": "https://schema.org", "@graph": [ organizationSchema({ name: "Your Product", url: "https://example.com/" }), { "@type": "Product", name: "Pro Plan", description: "...", offers: { "@type": "Offer", price: "19.00", priceCurrency: "EUR" }, }, ],},Use the schema.org type page to check its fields, then use the Rich Results Test in step 4 to check the rendered page.
The metadata query can feed the page-head fields as well:
const head: ApexHead = { // ...title, description, canonicalUrl siteName: seoConfig.organizationName, twitterSite: seoConfig.twitterSite || undefined, ogImage: seoConfig.defaultOgImage || undefined, schemaJson: { "@context": "https://schema.org", "@graph": [ organizationSchema({ name: seoConfig.organizationName, logoUrl: seoConfig.organizationLogoUrl || undefined, url: canonicalUrl, }), webPageSchema({ name: pageTitle, url: canonicalUrl }), ], },};4. Verify
Section titled “4. Verify”Check the responses, not only the TypeScript build:
curl --fail-with-body https://your-app.example/sitemap.xmlcurl --fail-with-body https://your-app.example/llms.txtcurl --fail-with-body https://your-app.example/robots.txt # only if robotsPolicy was setcurl --fail-with-body https://your-app.example/ | grep -A2 'application/ld+json'Confirm that the sitemap contains only intended published URLs, llms.txt
contains the summary, and the JSON-LD block is valid JSON with a non-empty
name. On a preview host, confirm that robots.txt contains Disallow: /.
Then run the external checks:
- Google Rich Results Test: paste the page URL and inspect the recognized nodes.
- Google Search Console: submit
sitemap.xmlafter verifying the domain. - Bing Webmaster Tools: submit the site when it is ready for Bing.
Reality check
Section titled “Reality check”Sitemap, robots.txt, and JSON-LD make pages easier to crawl and parse. They do
not determine rankings by themselves. Content, links, and page performance
still matter.
See also
Section titled “See also”seofeature reference, full options, config keys, dependencies.- Content & SEO bucket overview :
how
seorelates totemplate-resolver/legal-pages/managed-pages. recipes-apex-landing, a full working example:organizationSchema/webPageSchemainApexHead, pluscreateSeoFeaturemounted alongside the landing route.