> ## Documentation Index
> Fetch the complete documentation index at: https://www.commercengine.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# SEO & AEO Package

> Use @commercengine/seo for structured data, canonical metadata, robots, sitemaps, llms.txt, Markdown mirrors, and framework adapters.

`@commercengine/seo` is the opinionated discovery layer for a Commerce Engine storefront. It consumes canonical Commerce Engine catalog entities and generates the surfaces search engines, answer engines, and agents need without requiring each storefront team to become a Schema.org or crawler-infrastructure expert.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
pnpm add @commercengine/seo @commercengine/storefront
```

## Design goals

The package is built around four rules:

1. **Correct by default.** Product variants, offers, canonical URLs, crawler policy, cache variation, and sitemap limits are package concerns rather than application-level toggles.
2. **One public route model.** The same product/category URL resolvers feed head metadata, JSON-LD, Markdown, sitemaps, and `@commercengine/ai`.
3. **Deployment mode over framework.** Runtime server → one request handler. Static output → one prebuild script.
4. **No parallel commerce schema.** Product, variant, category, pricing, attribute, and image data come from Commerce Engine's generated Storefront API types.

## Configure site identity and routes

Keep the public definition in a browser-safe module:

```typescript src/lib/commerce-seo.config.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { defineCommerceSeoConfig } from "@commercengine/seo/config";

export const commerceSeo = defineCommerceSeoConfig({
  site: {
    name: "Acme",
    url: "https://acme.example",
    brandName: "Acme",
    description: "Designed for everyday use.",
    logoUrl: "https://acme.example/logo.png",
    locale: "en_US",
  },
  routes: {
    productBase: "/product",
    categoryBase: "/collections",
  },
});

export const { site, routes } = commerceSeo;
```

Then attach the storefront in server/build code:

```typescript src/lib/seo.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { createCommerceSeo } from "@commercengine/seo";
import { storefront } from "./storefront";
import { commerceSeo } from "./commerce-seo.config";

export const seo = createCommerceSeo({ ...commerceSeo, storefront });
```

The framework-neutral core accepts either a first-party `@commercengine/storefront` wrapper or the canonical `StorefrontFactory` returned by `createStorefront()`.

<Info>
  If you omit route configuration, the package defaults to `/products/:slug`, `/category/:slug`, and `/search`. The Commerce Engine starter projects explicitly set `productBase: "/product"` because that is their storefront URL convention. Base segments may also be `/` when a storefront intentionally serves an entity type at the site root; the route helpers normalize root bases to `/slug` rather than `//slug`.
</Info>

<Warning>
  Never import the module exporting `seo` into client code in Next.js, Astro SSR, SvelteKit SSR, or TanStack Start. Import the pure `commerce-seo.config.ts` instead. This prevents a server storefront from leaking into the browser bundle.
</Warning>

## Product and category head data

Framework-neutral code can ask the instance for a complete head model:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const head = await seo.productHead(product);
// head.title
// head.meta
// head.links
// head.scripts  // safely serialized JSON-LD
```

Framework adapters convert that model to the native framework shape:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
// Next.js
import { createProductMetadata } from "@commercengine/seo/nextjs";
return createProductMetadata(seo, product);

// Astro
import { createAstroProductHead } from "@commercengine/seo/astro";
const head = await createAstroProductHead(seo, product);

// SvelteKit
import { createSvelteKitProductHead } from "@commercengine/seo/sveltekit";
const head = await createSvelteKitProductHead(seo, product);

// TanStack Start
import { createTanStackStartProductHead } from "@commercengine/seo/tanstack-start";
const head = await createTanStackStartProductHead(seo, product);
```

Product head data includes canonical URL, description, Markdown alternate, Open Graph, Twitter metadata, and product JSON-LD. Category helpers provide the equivalent collection metadata.

### Breadcrumbs stay page-aware

The package cannot know your site hierarchy, so `Product`/`ProductGroup` schema and breadcrumbs are deliberately separate. Build the breadcrumb from resolved URLs, not string concatenation:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const category = product.categories?.[0];
const [productUrl, categoryUrl] = await Promise.all([
  seo.productUrl(product),
  category ? seo.categoryUrl(category) : Promise.resolve(null),
]);

const breadcrumb = seo.breadcrumbJsonLd([
  { name: "Home", url: seo.config.site.url },
  ...(category && categoryUrl ? [{ name: category.name, url: categoryUrl }] : []),
  { name: product.name, url: productUrl ?? seo.config.site.url },
]);
```

## Structured product data

A non-variant product is emitted as `Product` with its `Offer`, price, currency, stock state, sale/strikethrough pricing, reviews when available, images, brand, and catalog attributes.

A variant product is emitted as `ProductGroup` with `hasVariant` product entities and variant-specific offers. The package preserves every catalog variant axis:

* recognized Schema.org properties such as color, size, material, pattern, and `suggestedGender` use their native product property
* custom axes such as Metal, Finish, or Carat are kept in `variesBy` and emitted as `PropertyValue` entries through `additionalProperty`
* the package does not invent nonexistent Schema.org URLs for merchant-specific option names

This lets a storefront describe its full merchandising model without losing custom axes or producing invalid product properties.

## Custom routes and CMS-owned slugs

The simple case needs only `productBase` and `categoryBase`. Beyond that, the shape you need depends on one question: **does one product have one page, or several?**

<Tabs>
  <Tab title="One page per product">
    Supply forward and inverse resolvers. The forward function answers *"what is this product's URL?"*; the inverse maps an inbound public slug back to a Commerce Engine identifier, without which `/products/editorial-speaker.md` is looked up in the catalog by the literal string `editorial-speaker` and 404s.

    ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
    const commerceSeo = defineCommerceSeoConfig({
      site,
      routes: {
        product: ({ productId, variantSlug }) =>
          cms.productUrl(productId, variantSlug),
        category: (category) => cms.categoryUrl(category.id),

        // Inbound public slug → CE identifier for Markdown and agent requests.
        resolveProductRoute: (publicSlug) => cms.productId(publicSlug),
        resolveCategoryRoute: (publicSlug) => cms.categoryId(publicSlug),
      },
    });
    ```

    Returning `null` from a public route resolver intentionally removes that entity from public discovery surfaces.
  </Tab>

  <Tab title="Several pages per product">
    A resolver returns one URL, so where six CMS landing pages sell one catalog product, five never reach a sitemap. Declare **route records** instead: each page states its own path and names the entity behind it.

    ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
    import { createCmsRoutes } from "@commercengine/seo/routes";

    export const seo = createCommerceSeo({
      ...commerceSeo,
      storefront,
      routes: createCmsRoutes({
        source: async () => [
          { kind: "product",  path: "/products/rub-on-relief",        productId: "01H…", productSlug: "easy-to-rub-emulsion" },
          { kind: "product",  path: "/products/easy-to-rub-emulsion", productId: "01H…", productSlug: "easy-to-rub-emulsion" },
          { kind: "product",  path: "/products/discontinued-amp",     productId: "01J…", markdown: false },
          { kind: "category", path: "/category/audio",                categoryId: "01K…", categorySlug: "audio" },
        ],
        ttlMs: 60_000,
      }),
    });
    ```

    One declaration answers the three questions a CMS-fronted storefront actually has:

    | Question                       | Surface                             | Answer                                            |
    | ------------------------------ | ----------------------------------- | ------------------------------------------------- |
    | Which page is being rendered?  | PDP head, `.md` mirror, metadata    | the hint you pass to `productPage(product, hint)` |
    | Which page do I link to?       | cards, category rows, agent results | the primary record                                |
    | What are all the public pages? | `sitemap.xml`, static generation    | every record                                      |

    The primary is the record marked `primary: true`, or failing that the record whose segment equals the catalog slug. When nothing names one, the link resolves to `null` and the entity is reported by `manifest.conflicts()` — a non-link rather than a destination chosen by `sort()`. Pass `ambiguous: "first"` to take the lexically first route instead.

    `markdown: false` says the page exists but its Markdown mirror does not. It stays in `sitemap.xml` and nothing advertises a `.md` that has nothing to render, but the two deployment modes place it differently in `/sitemap.md`:

    | Deployment        | `/sitemap.md` entry for a `markdown: false` page                               |
    | ----------------- | ------------------------------------------------------------------------------ |
    | Runtime handler   | Listed as the **HTML** page, with no `.md` suffix                              |
    | Static generation | Absent — that index is built from the assets actually emitted, and no file was |

    Neither advertises a mirror that 404s. They differ only in whether an agent reading `sitemap.md` learns the page exists; `sitemap.xml` lists it in both modes.

    For client components, `createCommerceRouteManifest` resolves the same records **synchronously**, so a product card can write `href={manifest.productPath(item) ?? undefined}` without awaiting.

    <Warning>
      When `productPath` returns `null`, render a non-link. Falling back to `/products/{catalog-slug}` reintroduces exactly the 404 this model exists to prevent — that URL is the one the CMS route does not serve.
    </Warning>
  </Tab>
</Tabs>

You can also enrich title, description, canonical URL, images, or brand without replacing the underlying Commerce Engine entity:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const seo = createCommerceSeo({
  ...commerceSeo,
  storefront,
  enrichProduct: async (product) => ({
    title: await cms.seoTitle(product.id),
    description: await cms.seoDescription(product.id),
  }),
});
```

Returning `null` from a public route resolver intentionally removes that entity from public discovery surfaces.

## Machine-readable storefront surfaces

The package can serve or generate:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
/robots.txt
/sitemap.xml
/sitemap/{id}.xml       # when sharding is required
/llms.txt
/sitemap.md
{productBase}/{slug}.md
{categoryBase}/{slug}.md
{searchPath}.md?q=term  # server deployments
```

Defaults are `productBase = "/products"`, `categoryBase = "/category"`, and `searchPath = "/search"`. In the shared `routes` config the search key is `routes.search`; `searchPath` is the optional per-handler override. Product/category handler overrides are `productPath` and `categoryPath` regular expressions.

On a server deployment, the canonical product/category URL can also return Markdown when the client prefers `text/markdown`. The response varies on `Accept` so a CDN cannot mix HTML and Markdown representations.

### Server deployment

`/llms.txt`, `/sitemap.md`, Markdown mirrors, and `Accept: text/markdown` negotiation are always handled by the request adapter. `robots.txt` and XML sitemaps are deliberately opt-in so an integration cannot silently shadow routes the storefront already owns.

Enable both when the Commerce Engine adapter should own them:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const handler = createCommerceSeoRequestHandler(seo, {
  robots: true,
  sitemap: true,
});
```

The framework guides show the native wrapper for Next.js, Astro, SvelteKit, and TanStack Start. Static generation is the opposite: robots and XML sitemap files are emitted by default unless you disable them explicitly.

### Static deployment

Write real files before the framework build:

```javascript scripts/generate-seo-assets.mjs theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { writeCommerceSeoAssets } from "@commercengine/seo/build";
import { seo } from "../src/lib/seo.js";

// Returns the number of files written. Assets stream to disk one bounded batch at a
// time, so a large catalog never holds every generated Markdown body in memory.
const written = await writeCommerceSeoAssets(seo, { outDir: "public" });
console.log(`[seo] wrote ${written} assets`);
```

Use `static` instead of `public` for SvelteKit `adapter-static`.

<Card title="Deployment and indexability" icon="server" href="/docs/seo-ai/deployment">
  Server versus static recipes, robots behavior, preview deployments, generated files, and deployment checks.
</Card>

## Indexability and preview deployments

`indexable` is an override, not a flag that defaults to `false`:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
indexable: true   // force indexability
indexable: false  // force noindex
// omitted        // detect the deployment; only proven production is indexable
```

Recognized production deployments are indexable without setting the flag. Preview, development, and unknown environments resolve non-indexable. This matters because Vercel, Netlify, and Cloudflare preview builds commonly still have `NODE_ENV=production`.

A non-indexable deployment deliberately remains crawlable: generated `robots.txt` allows `/` and advertises no sitemap, while HTML metadata and served responses carry `noindex, nofollow`. Blocking the crawler with `Disallow: /` would prevent it from reading the noindex directive.

For a local static prebuild with no deployment-provider signal, explicitly setting `indexable: true` is appropriate only when you are intentionally generating the production artifact. For static output, the generator can also produce host-level noindex headers where supported because `.md` and `.txt` files have no HTML meta-tag equivalent.

<Warning>
  Do not let a preview URL become your configured canonical origin. `site.url` is authoritative, and one wrong host poisons canonical tags, sitemaps, breadcrumbs, Markdown links, and agent navigation at once.
</Warning>

## Remove overlapping hand-written SEO first

Before wiring the package, remove or reconcile:

* committed/static `robots.txt`, `sitemap.xml`, `llms.txt`, or Markdown routes that would shadow generated ones
* hand-authored `Product` JSON-LD on PDPs
* layout-level Open Graph defaults that render before product/category-specific tags
* product/category URL construction that bypasses `seo.productUrl()` or `seo.categoryUrl()`

These problems often fail silently: the app builds, but a crawler reads the wrong first tag or a static file shadows the package route.

## Key exports

| Import                                     | Use                                                                         |
| ------------------------------------------ | --------------------------------------------------------------------------- |
| `@commercengine/seo/config`                | pure site and route config safe in client/server code                       |
| `@commercengine/seo/routes`                | client-safe route records, `createCmsRoutes`, `createCommerceRouteManifest` |
| `@commercengine/seo`                       | SEO instance, head/JSON-LD/Markdown primitives                              |
| `@commercengine/seo/server`                | portable Web Request handler and individual discovery handlers              |
| `@commercengine/seo/build`                 | Node-only static asset writer                                               |
| `@commercengine/seo/nextjs`                | Next metadata helpers                                                       |
| `@commercengine/seo/nextjs/server`         | Next proxy/middleware adapter                                               |
| `@commercengine/seo/astro`                 | Astro head helpers                                                          |
| `@commercengine/seo/astro/server`          | Astro runtime middleware                                                    |
| `@commercengine/seo/sveltekit`             | SvelteKit head helpers                                                      |
| `@commercengine/seo/sveltekit/server`      | SvelteKit server hook                                                       |
| `@commercengine/seo/tanstack-start`        | TanStack Start head helpers                                                 |
| `@commercengine/seo/tanstack-start/server` | TanStack request middleware                                                 |

## Next steps

<CardGroup cols={2}>
  <Card title="Deployment & indexability" icon="cloud" href="/docs/seo-ai/deployment">
    Choose server or static serving correctly.
  </Card>

  <Card title="AI agent tools" icon="wand-magic-sparkles" href="/docs/seo-ai/ai">
    Add actionable WebMCP capabilities using the same routes.
  </Card>
</CardGroup>
