Skip to content

Make your apex/content pages SEO/AEO/GEO discoverable

The seo feature reference documents what fields exist. This guide is the missing how: mount it, fill in the five config values, wire real JSON-LD into your page head, and check the output the way a crawler or an answer engine would — before you assume it’s done.

  • 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.
import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
createSeoFeature({
sitemapEntries: (host) => [{ loc: `https://${host}/` }],
includeLegalPages: true, // only if createLegalPagesFeature() is also mounted
managedPages: { resolveApexTenant }, // only if createManagedPagesFeature() is also mounted
robotsPolicy: (host) => ({ allow: true, sitemapUrl: `https://${host}/sitemap.xml` }),
});

sitemapEntries is the one thing only your app knows: the list of public pages worth indexing. includeLegalPages/managedPages are opt-in merges — set them only if you’ve actually mounted those features, seo can’t detect that on its own. robotsPolicy is optional; skip it if a static public/robots.txt already covers your case (the common case).

Five tenant-scoped config keys feed the Organization JSON-LD helper and the llms.txt summary line: seo-organization-name, seo-organization-logo-url, seo-twitter-site, seo-llms-summary, seo-default-og-image. Nothing renders without them — they default to empty strings, not placeholders.

For values that are the same for every tenant (typical for a single-brand app), set them once as app-wide config overrides at boot — the same pattern file-foundation:config:provider already uses:

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

For a multi-tenant app where each tenant has its own brand (an agency platform, say), skip the override and let tenants set these through whatever config-write path your app already exposes — they’re regular createTenantConfig keys, admin-writable by default.

What makes a good seo-llms-summary: answer engines quote this text directly. “Cashcolt” is not a summary; “a free loan and amortization calculator, hosted in Germany, no tracking” is. Write it the way you’d answer “what does this do?” to someone who’s never heard of the product.

What JSON-LD actually is, in one paragraph: it’s a <script type="application/ld+json"> block in your page’s <head> containing a plain JSON object that describes the page using schema.org’s vocabulary — a shared dictionary of types (Organization, WebPage, FAQPage, Product, …) and their fields, that both search engines and LLM crawlers already know how to parse. @context says “I’m using schema.org’s vocabulary”; @type says which entry in that vocabulary this object is; everything else is that type’s fields. @graph is just “here’s an array of several such objects on one page” instead of one.

seo exports three pure builder functions for the types you need most often — plug their output into 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,
}),
],
},
};

Got an FAQ section on the page? faqPageSchema(items) is the most directly LLM-legible shape there is — question/answer pairs an answer engine can quote verbatim. Don’t invent FAQ content to get the badge; an empty or padded FAQ block reads as spam to both Google and an LLM crawler.

Need a type the three builders don’t coverProduct, Article, BreadcrumbList, LocalBusiness, SoftwareApplication, whatever fits your page — there’s no fourth builder for it, and there doesn’t need to be: schemaJson accepts any plain object, so write the node by hand 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" },
},
],
},

Two ways to check the shape is right before it ships: schema.org’s own type pages list every field per type (required vs. recommended), and the Rich Results Test in step 4 below tells you immediately if Google can’t parse what you wrote.

Don’t trust that it compiles — check what actually gets served:

Terminal window
curl -s https://your-app.example/sitemap.xml
curl -s https://your-app.example/llms.txt
curl -s https://your-app.example/robots.txt # only if robotsPolicy was set
curl -s https://your-app.example/ | grep -A2 'application/ld+json'

Confirm: the sitemap lists the URLs you expect (and nothing from a draft/ unpublished page), llms.txt has your actual summary — not an empty Blockquote — and the JSON-LD <script> block parses as valid JSON with a non-empty name.

Then check it the way the outside world does:

Sitemap, robots.txt, and JSON-LD make your pages findable and parseable — they are necessary, not sufficient. They don’t move a ranking on their own; that still comes down to content quality, real backlinks, and page performance. What seo buys you is: nothing standing in a crawler’s or an answer engine’s way. The rest is still writing.