Skip to content

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.

  • config feature mounted (a hard requirement of seo).
  • A marketing/apex page rendered via renderApexPage (ApexHead) or a CMS page via wrapInLayout (legal-pages/managed-pages). If neither exists yet, seo still 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.

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.

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.

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 }),
],
},
};

Check the responses, not only the TypeScript build:

Terminal window
curl --fail-with-body https://your-app.example/sitemap.xml
curl --fail-with-body https://your-app.example/llms.txt
curl --fail-with-body https://your-app.example/robots.txt # only if robotsPolicy was set
curl --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:

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.