@commercengine/seo
v0.3.0
Published
Opinionated SEO, AEO, structured data, and Markdown discovery for Commerce Engine storefronts
Downloads
307
Maintainers
Readme
@commercengine/seo
Opinionated SEO, answer-engine optimization (AEO), and agent-readable Markdown for Commerce Engine storefronts.
Included
- Schema.org
Product,ProductGroup, complete variant axes, variant offers, sale pricing, availability, catalog attributes, collections, organization, website search, and breadcrumbs. - Canonical, Open Graph — including
og:type: productwith price and availability — Twitter, locale, Markdown alternate, and safely serialized JSON-LD head data. - Product, category, search, and sitemap Markdown with YAML frontmatter and explicit
.mdlinks. - Dynamic
/llms.txt,/sitemap.md,robots.txt, and split XML sitemaps that enforce the 50,000 URL limit. - Content negotiation through
Accept: text/markdown, with explicit.mdmirrors for agents that cannot set headers. - Framework-neutral robots policy and XML sitemap generation, shared by every adapter and served as Web
Responseobjects. - Native Vercel, Netlify, and Cloudflare deployment detection, keeping non-production deployments crawlable while applying
noindexto head data, metadata, delegated HTML, served documents, and static assets. - First-party server adapters for Next.js, Astro, SvelteKit, and TanStack Start, build-time static assets for Astro and SvelteKit, plus a Web-standard request handler for other frameworks.
- Route and content enrichment hooks for storefronts whose public URLs or editorial metadata differ from Commerce Engine catalog data.
- A client-safe routing layer — route records, a synchronous manifest, primary selection and enumeration — so a crawler, an AI agent and a product card resolve the same URL for the same product.
Install
pnpm add @commercengine/seo @commercengine/storefrontInstall the peer for the framework adapter you use (next, astro, @sveltejs/kit, or @tanstack/react-start).
Core setup
Declare site identity and route shapes in their own module. It imports no storefront, so anything may import it — including browser code:
// src/lib/commerce-seo.config.ts
import { defineCommerceSeoConfig } from "@commercengine/seo/config";
export const commerceSeo = defineCommerceSeoConfig({
site: {
name: "Acme",
url: "https://acme.example",
description: "Acme's official online store.",
brandName: "Acme",
logoUrl: "https://acme.example/logo.png",
locale: "en_US",
},
routes: { productBase: "/product", categoryBase: "/category" },
});Then create the instance where the storefront lives:
// src/lib/commerce-seo.ts
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 also accepts the canonical StorefrontFactory returned by createStorefront() from @commercengine/storefront-sdk. Framework applications should normally use the customer-facing @commercengine/storefront package shown above.
site.url, routes.search and the route bases are checked at construction, because every canonical, sitemap entry and agent-facing URL is built from them and a wrong one is invisible until something crawls it.
site.urlmust be anhttp/httpsorigin with no path, query, fragment or credentials —https://user:[email protected]would otherwise publish those credentials in a public sitemap. The parsed origin is what the instance carries, so a stray space cannot pass construction and then throw on first use.routes.search,routes.productBaseandroutes.categoryBasemust be paths on this site. A leading//or/\is a network-path reference: it passes astartsWith("/")check and then resolves to another host, putting a cross-origin canonical on every page. Bare paths, malformed escapes, dot segments, and encoded separators are rejected because framework matchers would interpret them differently.routes.searchis normalized, so"/search/"cannot advertise/search.mdwhile the handler serves/search/.md. A query survives (/search?type=all) and is matched by pathname.
Use public catalog access for build-time and request-time SEO:
const { data, error } = await storefront.publicStorefront().catalog.getProductDetail({
product_id: "speaker",
});
if (error || !data) throw new Error("Product not found");
const jsonLd = await seo.productJsonLd(data.product);
const markdown = await seo.productMarkdown(data.product);Client and server boundaries
Never import the module that exports seo from client code.
createCommerceSeo holds a storefront client. On a framework wrapper that is a server-bound object, so the module exporting the instance is server-only. If that same module also exports site or routes, then client code reaching for them — to register agent tools, to build a canonical link — pulls the storefront in with it. That leak typechecks, builds, and passes tests. It shows up only when something inspects the bundle.
@commercengine/seo/config exists to make the safe shape the obvious one:
| Module | Imports a storefront | Safe to import from |
| --- | --- | --- |
| commerce-seo.config.ts → @commercengine/seo/config | no | anywhere, including the browser |
| commerce-routes.ts → @commercengine/seo/routes | no | anywhere, including the browser |
| commerce-seo.ts → @commercengine/seo | yes | server, build scripts, request handlers |
Both browser-facing entries pull in nothing but pure modules, which a test asserts against the built bundle rather than trusting review. /config declares what the storefront is; /routes resolves where its pages are, and is what a product card, a live search result and an agent tool all import. Route helpers — productPath, categoryPath, normalizeBase — are exported from both, so client code can build a URL without touching an instance.
@commercengine/ai takes the same config, and its own entries reference commerce types only as types, so nothing survives to runtime:
// A client component: config, no instance.
import { commerceSeo } from "@/lib/commerce-seo.config";
await registerCommerceWebMcp({ storefront, siteUrl: commerceSeo.site.url, routes: commerceSeo.routes });This mirrors how @commercengine/storefront separates /nextjs/client from /nextjs/server, and why clientStorefront() throws when called on the server.
Structured data
Variant axes
A product with variants is emitted as a ProductGroup whose variesBy declares every axis the catalog varies by, not only the ones Google has a property for. How an axis is emitted depends on whether Schema.org defines a property for it:
| Option | variesBy | On each variant |
| --- | --- | --- |
| any option of type color | https://schema.org/color | color: "Blue" |
| keyed size | https://schema.org/size | size: "M" |
| keyed material, pattern, suggested_gender | the matching https://schema.org/… URI | that property, e.g. material: "Cotton" |
| anything else — metal, finish, carat, … | "Metal" | additionalProperty: [{ "@type": "PropertyValue", name: "Metal", value: "Gold" }] |
Both rows are valid Schema.org. variesBy is typed Text, and Schema.org's own guidance is that its properties may be referenced by name while terms defined elsewhere use a URI — so an arbitrary axis name is a legal value, and it is emitted as plain text rather than as an invented https://schema.org/metal that would resolve to nothing.
The split matters on the variant side. Google matches variant rich results on a fixed set of URIs, and those are simultaneously real Product properties, so a recognized axis is emitted natively. An unrecognized one is not a Product property at all — metal: "Gold" would be undefined markup that validators flag — so it travels as additionalProperty, which is the vehicle Schema.org defines for exactly this. Nothing is dropped either way.
Colour is identified by the catalog's own option type rather than by name. type: "color" is a distinct option type whose values carry a hexcode instead of being plain strings, so it is checkable rather than guessable — Colour, Shade, and Farbe all resolve, with no list of spellings to maintain and nothing to add for a non-English storefront.
Every other axis is matched on its name, exactly, modulo case and separators: suggested_gender, suggestedGender, and Suggested Gender all resolve to the same property. Name the property and you get the property — including its value exactly as you wrote it, since this package has no opinion about your value vocabulary. It never guesses that a differently named option means the same thing, so an option keyed gender is not read as suggestedGender.
Google's sixth variant property, suggestedAge, is not claimed. Its range is QuantitativeValue ({ minValue: 13, unitCode: "ANN" }), which a free-text catalog option cannot express. Deriving one would mean this package deciding which value formats are acceptable — 13+ but not 13 plus? — and silently downgrading whatever it failed to parse. Being opinionated about merchant data is the thing to avoid, so a suggested_age option travels through additionalProperty with its value intact, like any other axis.
Product-level merchandising attributes reach additionalProperty on the Product or ProductGroup by the same route, so material, weight, and dimensions are no longer visible only in the Markdown mirror. An attribute that repeats a variant axis is left to the variants, so a ProductGroup never asserts one fixed value for a property it also declares in variesBy.
Serving the SEO surfaces
Six surfaces need to be served: robots.txt, sitemap.xml (plus sitemap/{id}.xml shards), llms.txt, sitemap.md, and a .md mirror for every product and category.
How you serve them depends on one thing: whether your deployment has a server at request time. Not on which framework you use.
| Deployment | Recipe | Files |
| --- | --- | --- |
| Server at runtime — Next.js, TanStack Start, SvelteKit on a server adapter, Astro on an adapter, Nuxt, any Node/edge host | one request middleware | 1 |
| Static output — adapter-static, Astro's default, any single-page app | one prebuild script | 1 |
Both recipes serve all six surfaces. Pick by adapter, not by framework.
Server at runtime
Mount the framework's middleware with robots and sitemap turned on. That is the whole integration:
// Next.js — src/proxy.ts (or src/middleware.ts on Next 15)
import { createNextjsSeoProxy } from "@commercengine/seo/nextjs/server";
import { seo } from "@/lib/seo";
export default createNextjsSeoProxy(seo, { robots: true, sitemap: true });
export const config = {
matcher: [
"/product/:path*",
"/category/:path*",
"/search",
"/search.md",
"/llms.txt",
"/sitemap.md",
"/robots.txt",
"/sitemap.xml",
"/sitemap/:path*",
],
};The equivalent for each framework, same options:
// TanStack Start — src/start.ts
import { createTanStackStartSeoMiddleware } from "@commercengine/seo/tanstack-start/server";
export const startInstance = createStart(() => ({
requestMiddleware: [createTanStackStartSeoMiddleware(seo, { robots: true, sitemap: true })],
}));
// SvelteKit (server adapter) — src/hooks.server.ts
import { createSvelteKitSeoHandle } from "@commercengine/seo/sveltekit/server";
export const handle = createSvelteKitSeoHandle(seo, { robots: true, sitemap: true });
// Astro (with an adapter) — src/middleware.ts
import { createAstroSeoMiddleware } from "@commercengine/seo/astro/server";
export const onRequest = createAstroSeoMiddleware(seo, { robots: true, sitemap: true });Anything else — Nuxt, Hono, Express, a Worker — uses the portable handler directly:
import { createCommerceSeoRequestHandler } from "@commercengine/seo/server";
const handle = createCommerceSeoRequestHandler(seo, { robots: true, sitemap: true });
export default async function fetch(request: Request): Promise<Response> {
return (await handle(request)) ?? yourApp(request);
}Next.js matchers are exact. "/search" does not match /search.md, so list both. A missing entry produces a silent 404 on that one surface while every other works.
Static output
No server exists at request time, so the assets are written as real files into the directory your framework publishes verbatim:
// scripts/generate-seo-assets.mjs
import { writeCommerceSeoAssets } from "@commercengine/seo/build";
import { seo } from "../src/lib/seo.js";
const assetCount = await writeCommerceSeoAssets(seo, { outDir: "public" });
console.log(`[seo] wrote ${assetCount} assets`);{ "scripts": { "build": "node scripts/generate-seo-assets.mjs && vite build" } }| Framework | outDir |
| --- | --- |
| Vite / React SPA, Astro, Next.js | public |
| SvelteKit | static |
@commercengine/seo/build is Node-only by design, so ./static stays importable from a browser, worker, or edge bundle.
The writer generates and writes a bounded product batch at a time; it does not retain every
Markdown body for a large catalog. createCommerceSeoStaticAssets() remains available when a
framework requires an in-memory array. Use iterateCommerceSeoStaticAssets() for incremental
custom build integrations. Incremental output is not atomic: if generation fails late, files already
written remain in outDir. Point the writer at a fresh staging directory and publish or swap that
directory only after it succeeds when an atomic deployment is required.
Do not mount routes for a static build. Framework routers give a concrete route priority over a catch-all, so a /product/[slug] page claims /product/shoe.md before any catch-all sees it — SvelteKit fails the build outright, and others silently serve HTML where Markdown was expected. A file in the published directory is not subject to routing at all.
Add the generated paths to .gitignore; they are build output:
/public/robots.txt
/public/sitemap.xml
/public/sitemap.md
/public/llms.txt
/public/product/
/public/category/Astro's default output can also use a catch-all endpoint, src/pages/[...commerceSeo].ts with createAstroSeoStaticEndpoint, which works because Astro pages are concrete paths. The prebuild script is still preferable — it cannot be out-prioritised by a route.
Before you start: remove what already exists
Every storefront starter ships its own SEO, and the overlaps fail quietly rather than loudly.
public/robots.txt(orstatic/robots.txt) shadows the dynamic route with no error. Your robots.txt is simply not this package's, and nothing says so. Delete it.- Hand-written JSON-LD in a product or category page becomes a second, conflicting entity for the same URL. Remove it before adding
productJsonLd. - Layout-level Open Graph tags —
og:image,og:site_name— render before page-level head data, so a generic fallback image wins on social shares. Gate them on the page not supplying its own, or remove them. - A hand-rolled
sitemap.xmlorllms.txtroute takes priority over the handler.
Per-route control
If you would rather mount surfaces individually — to add caching per route, or serve only some of them — every handler is exported:
import { handleRobotsTxt, handleSitemapXml, createSitemapRoutes } from "@commercengine/seo/server";createSitemapRoutes returns the index and shard routes together, because a sitemap index is only valid if every shard it advertises resolves:
export const sitemap = createSitemapRoutes(seo);
// sitemap.index() → /sitemap.xml
// sitemap.shard(id) → /sitemap/{id}.xml
// sitemap.shardIds() → ids that exist, for prerenderingTwo framework constraints if you take this path:
- Next.js — a folder named
[id].xmlis not a dynamic segment;paramsarrives as{}and the build fails. Use a plain[id]segment and passshardUrl: (id) => \/sitemap/${id}`` so the index advertises what you mounted. - TanStack Start — route params must be valid JavaScript identifiers, so
$slug.mdis rejected. Markdown mirrors require the request middleware; there is no file-route equivalent.
Known limit: 100 categories
Commerce Engine's list-categories endpoint accepts no page or limit parameters, and returns at most 100 top-level categories. Nothing this package can do reaches category 101.
A store with more than 100 top-level categories will therefore see the remainder missing from llms.txt, sitemap.md, XML sitemaps and static output — and, because inbound slug resolution searches the same list, a real category can resolve to a 404. Child categories are unaffected: nested_level: 4 returns them under their parents.
This is an upstream API limit, not a configuration choice. If it affects you, a routes.resolveCategoryRoute that maps slugs from your own CMS avoids the resolution half of the problem.
robots.txt and indexability
createRobotsPolicy returns a plain robots.txt record model; robotsTxt serializes it. Sitemap directives are resolved to fully qualified URLs, as the format requires.
The default policy withholds two different things from two different audiences:
| Withheld | From | Default | Why |
| --- | --- | --- | --- |
| Session and account routes | every crawler | /api/, /cart, /checkout, /checkouts/, /account, /login, /orders, /profile, /wishlist | Per-visitor or authenticated; nothing to offer anyone |
| Search and faceted URLs | search engines only | the configured search path, {categoryBase}/*?*sort=, {categoryBase}/*?*filter | Near-duplicates of pages already in the sitemap |
The asymmetry is deliberate. Faceted and search result pages spend crawl budget re-reading content the sitemap already lists. An agent is doing the opposite — llms.txt advertises the search path as a capability, and blocking it would contradict a document this package publishes for that audience. Override either list with disallow and searchEngineDisallow; faceted rules follow routes.categoryBase.
In development, robots.txt looks empty and that is correct. A dev server is not a proven production deployment, so the policy becomes User-agent: * / Allow: / with no sitemap advertised, and every surface carries noindex. See Preview deployments. Set indexable: true on the instance to see the production policy locally.
XML sitemaps
createSitemapEntries returns sitemaps.org URL records, createSitemapShards reports the shard indices a catalog needs, and sitemapXml/sitemapIndexXml serialize them. Root, category, and product URLs participate in the same shard ranges, so a category-heavy catalog shards as readily as a product-heavy one, and every emitted URL is validated against site.url.
handleSitemapXml serves a urlset when the catalog fits in one sitemap and a sitemapindex when it does not, so a large catalog is never published truncated or oversized.
Entry shape is a structural subset of Next.js MetadataRoute.Sitemap, which is how @commercengine/seo/nextjs restates core results in Next types without the core depending on Next.
Preview deployments
NODE_ENV cannot answer whether a deployment is the canonical production site: Vercel, Netlify, and Cloudflare all build preview deployments with NODE_ENV=production. Trusting it publishes an indexable preview of every branch, competing with the real storefront in search results.
The deployment is detected natively instead:
| Platform | Signal | Production when |
| --- | --- | --- |
| Vercel | VERCEL_ENV | production |
| Netlify | CONTEXT | production |
| Cloudflare Pages | CF_PAGES_BRANCH | branch is in productionBranches |
| Cloudflare Workers Builds | WORKERS_CI_BRANCH | branch is in productionBranches |
| Anything else | NODE_ENV | production |
Anything not provably production is treated as non-indexable, which means it stays crawlable and carries noindex everywhere.
That combination is deliberate and the ordering matters:
| Directive | Effect |
| --- | --- |
| Disallow: / | Stops the crawl. Does not deindex — and because the crawler never fetches the page, it never sees a noindex either. A linked URL can still be indexed without content. |
| noindex | Actually removes the URL from the index — but only if the crawler is allowed to fetch the page and read it. |
So a non-production deployment is served User-agent: * / Allow: / with no sitemap advertised, and noindex, nofollow on every surface:
robotsmeta in head data (createProductHead/createCategoryHead)robotsin Next.js metadata (createProductMetadata/createCategoryMetadata)X-Robots-Tagon every document this package servesX-Robots-Tagon every HTML response the middleware seesX-Robots-Tagon every generated static asset, plus a/_headersfile so a static host can actually apply it (see Static builds need host header configuration)
The second-to-last point depends on your matcher. Astro, SvelteKit and TanStack middleware run on every request, so a preview's cart, account and editorial pages are covered. Next.js middleware only runs on paths in config.matcher, and the matcher shown earlier is scoped to storefront content routes — so /cart, /account and /about would receive no header. Since robots.txt deliberately stays Allow: /, those pages would be crawlable with no directive at all, which is the exact failure this section exists to prevent.
On Next.js, widen the matcher for a deployment that serves previews:
export const config = {
// Everything except static assets and image optimization.
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Blocking the crawl would defeat all of it. Do not "harden" a preview by adding Disallow: /.
robots.txt cannot make a preview private. It is advisory, and the URLs are still reachable.
noindexkeeps a preview out of search results; only authentication keeps it out of public view. Use your platform's preview protection for anything confidential.
Indexability is resolved once per createCommerceSeo and exposed as seo.indexable. Everything above — including robots.txt — derives from that single value, so the crawl policy and the page directives can never disagree:
createCommerceSeo({ storefront, site, indexable: false }); // force noindex
createCommerceSeo({ storefront, site, deployment: { env } }); // supply the environmentDetection is best-effort, not a guarantee. Platforms outside the table fall back to NODE_ENV, and a self-hosted preview built with NODE_ENV=production is indistinguishable from production. On an undetected platform, set indexable explicitly.
Detection is also available on its own for other decisions:
import { detectDeploymentEnvironment, isProductionDeployment } from "@commercengine/seo";
detectDeploymentEnvironment(); // "production" | "preview" | "development" | "unknown"Cloudflare publishes no production/preview flag, only a branch name, so its production branch is matched by name against productionBranches, which defaults to ["main", "master"]. If your Cloudflare production branch is anything else, configure it on the SEO instance or production will serve the preview policy:
createCommerceSeo({ storefront, site, deployment: { productionBranches: ["release"] } });On runtimes without a Node process, such as Cloudflare Workers without nodejs_compat, supply the environment the same way: createCommerceSeo({ storefront, site, deployment: { env } }).
Indexability is not overridable per call. createRobots/handleRobotsTxt take no production flag, because a robots.txt that disagrees with the pages' noindex is the exact failure this design exists to prevent. Change indexable on the instance and every surface moves together.
Static builds need host header configuration
This is a real limitation, not a footnote. Prerendered output is plain files, which cannot carry HTTP headers themselves. HTML pages still get their robots meta tag, but .md, .txt, and .xml assets have no meta-tag equivalent — for those, an HTTP header is the only mechanism, and it must come from the host.
A non-indexable static build therefore also emits /_headers:
/*
X-Robots-Tag: noindex, nofollow| Host | Applies _headers | Notes |
| --- | --- | --- |
| Netlify | Yes | Read from the publish directory automatically. |
| Cloudflare Pages | Yes | Read from the build output directory automatically. |
| Vercel | No | Add the header to vercel.json, or set staticHeaders: true on the Astro Vercel adapter. |
| Other static hosts | No | Configure the header in your server or CDN. |
For Vercel:
{ "headers": [{ "source": "/(.*)", "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }] }] }Set includeHostHeaders: false if you ship your own _headers, and merge NOINDEX_HEADERS into it. The file is never emitted for an indexable build.
On a host that does not apply these headers, a static preview's .md and .txt assets remain indexable. Use your platform's preview protection if that matters.
Framework-neutral request handler
@commercengine/seo/server accepts standard Web Request objects and returns a Markdown Response or null when the framework should render HTML.
import { createCommerceSeoRequestHandler } from "@commercengine/seo/server";
import { seo } from "./commerce-seo";
export const handleSeoRequest = createCommerceSeoRequestHandler(seo, {
productPath: /^\/products\/([^/]+?)(?:\.md)?$/,
categoryPath: /^\/category\/([^/]+?)(?:\.md)?$/,
searchPath: "/search",
productLimit: 100,
sitemapLimit: 1_000,
});It serves:
GET /products/:slugwhenAccept: text/markdownis preferredGET /products/:slug.mdGET /category/:slugwhenAccept: text/markdownis preferredGET /category/:slug.mdGET /search.md?q=...GET /llms.txtGET /sitemap.md
Upstream catalog failures return 503 and are never cached as false 404 responses. Actual missing or intentionally unroutable resources return cache-limited 404 responses.
Each of those surfaces can be withdrawn, for a storefront adopting the package alongside routes it already owns:
createCommerceSeoRequestHandler(seo, {
surfaces: { categoryMarkdown: false, sitemapMarkdown: false },
});A withdrawn surface delegates instead of answering, and stops being declared negotiable — its URL then has one representation, so there is nothing to Vary on. robots and sitemap are separate options rather than members of surfaces because they default off and carry configuration of their own.
Vary: Accept and the prerender cache
Markdown responses this package generates always carry Vary: Accept. It is the HTML side that needs a caveat.
Every server adapter asks for Vary: Accept on delegated HTML, on configured product, category, and search URLs only — existing Vary fields are preserved, and unrelated routes keep their original cache key. Whether the header survives to the client is not the adapter's decision. A prerendered or full-route-cached HTML response is replayed from the cache with the Vary recorded at build time, so a value added afterwards by proxy or middleware is dropped. This is a framework-level constraint, not one this package can work around: Next's own commerce template ships HTML with vary: rsc, next-router-state-tree, … and no Accept for the same reason.
What this costs in practice is small, because negotiation is not the only way in. The .md mirror is a distinct URL with its own cache entry, so it is unaffected, and it is what llms.txt, sitemap.md, and every <link rel="alternate" type="text/markdown"> advertise. A shared cache that ignores Accept on the HTML URL still serves HTML to HTML clients; only same-URL negotiation is weakened behind such a cache.
If you do need same-URL negotiation to be cache-correct end to end, declare it where the cache reads it. On Next.js that is next.config.ts, whose headers() are applied by the routing layer above the full-route cache:
export default withCommerceSeo(nextConfig, { varyHtmlOnAccept: true });It is off by default because the cost is real: every distinct Accept string becomes its own shared-cache entry, and browsers send many.
Next.js
Serving is covered above — one proxy file for a server deployment, or a prebuild script for output: "export". This section is about page metadata.
Generate product metadata:
import { createProductMetadata } from "@commercengine/seo/nextjs";
import { seo, storefront } from "@/lib/commerce-seo";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const productId = await seo.resolveProductRoute(slug);
if (!productId) return {};
const { data } = await storefront.publicStorefront().catalog.getProductDetail({ product_id: productId });
return data?.product ? createProductMetadata(seo, data.product) : {};
}Open Graph on product pages
A product page should declare og:type: product and the Open Graph commerce block, which is what social and commerce scrapers read for price and availability. Next.js cannot express it: openGraph.type is a closed union with no product member, and metadata.other emits name= where Open Graph is defined over RDFa property=.
So createProductMetadata omits og:type — Open Graph already treats a missing og:type as website, so nothing regresses — and the page renders the block itself. React hoists the tags into <head>:
import { productOpenGraphTags } from "@commercengine/seo/nextjs";
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const product = await loadProduct((await params).slug);
return (
<>
{productOpenGraphTags(product).map((tag) => (
<meta key={tag.property} property={tag.property} content={tag.content} />
))}
{/* page content */}
</>
);
}Every other adapter gets this from createProductHead, which emits the block inline. The same tags are available framework-neutrally as productOpenGraphMeta(product).
Use the corresponding factories for these files:
| Route file | Factory |
| --- | --- |
| app/md/products/[slug]/route.ts | createProductMarkdownHandler |
| app/md/category/[slug]/route.ts | createCategoryMarkdownHandler |
| app/md/search/route.ts | createSearchMarkdownHandler |
| app/llms.txt/route.ts | createLlmsTxtHandler |
| app/sitemap.md/route.ts | createSitemapMarkdownHandler |
Astro
Serving is covered above: a middleware when you use an adapter, or a prebuild script for Astro's default static output. This section is about page head data.
Return framework-neutral head tags from page frontmatter and render them in the layout:
import { createAstroProductHead } from "@commercengine/seo/astro";
const seoHead = await createAstroProductHead(seo, product);---
const { seoHead } = Astro.props;
---
<head>
<title>{seoHead.title}</title>
{seoHead.meta.map((tag) => <meta {...tag} />)}
{seoHead.links.map((tag) => <link {...tag} />)}
{seoHead.scripts.map((tag) => <script type={tag.type} set:html={tag.content} />)}
</head>Gate any layout-level og:image or og:site_name on seoHead being absent, or both render and the layout's generic image wins.
SvelteKit
Serving is covered above: a handle on a server adapter, or a prebuild script writing into static/ for adapter-static. This section is about page head data.
createSvelteKitProductHead and createSvelteKitCategoryHead return head data suitable for <svelte:head>. Build it in the server load, since route resolution is async:
// +page.server.ts
return { product, seoHead: await createSvelteKitProductHead(seo, product) };<svelte:head>
<title>{data.seoHead.title}</title>
{#each data.seoHead.meta as tag}
{#if tag.property}<meta property={tag.property} content={tag.content} />
{:else}<meta name={tag.name} content={tag.content} />{/if}
{/each}
{#each data.seoHead.links as link}<link rel={link.rel} href={link.href} type={link.type} />{/each}
{#each data.seoHead.scripts as script}{@html `<script type="${script.type}">${script.content}</script>`}{/each}
</svelte:head>Static generation guarantees
- Filenames come from the final render context.
enrichProduct/enrichCategoryurloverrides decide where a file lands, so a document's ownmarkdown_urlfrontmatter always matches its path on disk. An entity whoseroutes.*resolver returnsnullis still generated when enrichment supplies a URL. - Omitted entities are skipped, not fatal. A product or category with no public route is left out of the build rather than failing it, matching the
nullcontract used everywhere else. - Discovery describes only enabled page families and representations that exist.
/llms.txtand/sitemap.mdare built from the Markdown assets that were emitted./sitemap.xmllists the corresponding HTML pages plus declared records markedmarkdown: false; those remain real pages even though the package writes no mirror for them.includeProducts: falseandincludeCategories: falseremove their respective families from all three discovery outputs. - Static builds get robots and XML sitemaps too.
robots.txtandsitemap.xmlare emitted alongside the Markdown assets, sharded into realsitemap/{id}.xmlfiles plus an index when the catalog exceedsmaxUrlsPerSitemap. Switch either off withincludeRobots: false/includeSitemapXml: false. Every emitted URL is validated againstsite.url. A non-indexable build stampsx-robots-tag: noindex, nofollowon every asset and emits/_headers, which only some hosts apply — see Static builds need host header configuration. - Variant
.mdlinks are only advertised when they exist. Query-string variant routes such as?variant=blueresolve to the product's own file and are always linked. Path-based variant routes such as/p/tee/bluelink the HTML URL by default; setincludeVariantMirrors: trueto publish a/p/tee/blue.mdmirror canonicalized to the variant, at the cost of one extra file per routable variant.
TanStack Start
Serving is covered above — one src/start.ts. Markdown mirrors require the request middleware: TanStack route params must be valid JavaScript identifiers, so a $slug.md file route is rejected outright.
head is synchronous, so build the head data in the loader and return it:
export const Route = createFileRoute("/product/$slug")({
loader: async ({ params }) => {
const product = await loadProduct(params.slug);
return { product, seoHead: await createTanStackStartProductHead(seo, product) };
},
head: ({ loaderData }) => loaderData?.seoHead ?? {},
});The returned shape is TanStack's own { meta, links, scripts }, so <HeadContent /> in your root route renders Open Graph tags and JSON-LD with no mapping.
Interactive agent tools
This package builds the surfaces a crawler or agent reads: structured data, Markdown, llms.txt, robots, and sitemaps. It deliberately does not register browser tools an agent can act through — cart mutations, navigation, and checkout are stateful, browser-only, and carry a different security model.
That surface lives in @commercengine/ai, which shares this package's route contract so both describe the same URLs. Point one routes object at each:
const routes = { productBase: "/product", categoryBase: "/collections" };
const seo = createCommerceSeo({ storefront, site, routes });
await registerCommerceWebMcp({ storefront, siteUrl: site.url, routes });Custom public routes and enrichment
Matching your URL convention
Route defaults follow the prevailing headless-commerce convention — /products/:slug and /category/:slug. When your storefront uses a different shape, set the base once:
const seo = createCommerceSeo({
storefront,
site,
routes: {
productBase: "/product", // e.g. the Commerce Engine starter templates
categoryBase: "/collections",
},
});That single value drives every surface together: JSON-LD url and @id, canonical tags, Markdown .md alternates, llms.txt, sitemap.md, XML sitemaps, and the request handler's own route matching. There is no second place to keep in sync, so the URL a crawler indexes and the URL an agent is handed cannot drift apart.
Leading and trailing slashes are normalized, so "product", "/product", and "/product/" are equivalent.
If products and categories deliberately share one namespace (for example both bases are /), one
generic Markdown matcher cannot know which entity kind a slug names. Disable one handler surface or
one Next rewrite and serve that family through the storefront's own route; the package fails at
adapter construction instead of silently sending every match to whichever family happened to run first.
Two places restate it because they cannot import the SEO instance:
// next.config.ts — cannot import the storefront SDK
export default withCommerceSeo({}, { productBase: "/product" });and the Next.js proxy config.matcher, which is a static export.
Full control
Use routes.product, routes.category, routes.search, enrichProduct, and enrichCategory when public URLs or editorial metadata differ from catalog data in ways a base segment cannot express:
const seo = createCommerceSeo({
storefront,
site,
routes: {
product: ({ productSlug, variantSlug }) =>
variantSlug ? `/p/${productSlug}/${variantSlug}` : `/p/${productSlug}`,
category: (category) => `/collections/${category.slug}`,
search: "/find",
// Generic inverse mapping for CMS-owned inbound route segments.
resolveProductRoute: async (publicSlug) =>
(await cms.productByPublicSlug(publicSlug))?.commerceEngineProductId ?? null,
resolveCategoryRoute: async (publicSlug) =>
(await cms.categoryByPublicSlug(publicSlug))?.commerceEngineCategoryId ?? null,
},
enrichProduct: async (product) => ({
description: await cms.productDescription(product.id),
}),
});routes.product and routes.category take precedence over productBase/categoryBase and map CE entities to public URLs. Their inverse hooks, resolveProductRoute and resolveCategoryRoute, map an inbound public slug back to a CE ID or CE slug before Markdown handlers query Commerce Engine. They are CMS-neutral and can call any route registry or content service.
Set routes.search: null when a storefront has no search page. Every surface that would otherwise assert one stops together: the llms.txt Browse section, the WebSite SearchAction, the robots policy's search rule, and the handler's /search and /search.md routes. seo.searchPath exposes the resolved value.
When several pages sell one product
Deriving a URL from a product answers "which page does this product have?". A CMS-backed storefront needs three questions answered, and that one is not even the common case:
| Question | Answered by | Used for |
|---|---|---|
| Which page is being rendered? | the hint, below | canonical, .md mirror, metadata |
| Which page should I link to? | the page the catalog names | product cards, category rows, agent results |
| What are all the public pages? | enumeration | sitemap.xml, /sitemap.md, static generation |
Answering all three with one derived URL is what deindexes five of six landing pages: they canonicalise onto the sixth, and the other five never appear in a sitemap at all. The last two are covered under CMS-owned URLs; the hint is what follows.
Every render path that knows the inbound route passes it down as a hint:
routes: {
// The requested page wins where there is one; catalog-wide passes still get a URL.
product: ({ productSlug, variantSlug }, hint) => {
const base = hint?.path ?? `/products/${productSlug}`;
return variantSlug ? `${base}?variant=${variantSlug}` : base;
},
}or, without writing a resolver at all:
enrichProduct: (product, hint) => (hint ? { url: hint.path, canonicalUrl: hint.path } : {}),The hint is { path, slug?, url? }, where path is the HTML route with any .md suffix removed — so a .md mirror and the page it mirrors never disagree about which page they are. It is supplied by the request handler and every framework middleware.
It is always the last parameter, on routes.product, routes.category, enrichProduct, enrichCategory, productContext, categoryContext, productPage, categoryPage, the head builders and the Next.js metadata builders. One concept, one position — writing a second resolver never means remembering a second calling convention.
It is always optional, and absent whenever the caller has no single route in view: XML sitemap entries, which produce URLs rather than render pages, and @commercengine/ai tool calls, where an agent is asking where a product lives rather than rendering it. A resolver that reads it must still answer without one. Static generation does supply it — it enumerates a product's routes and renders one document per route, so it knows exactly which page each pass is writing.
Without routes.list, a hint never changes resolution on its own. Whether a second route for one product is a distinct page (self-canonical) or an alias (canonical to the primary) is a decision only the storefront can make, and guessing either way would be wrong half the time. Read it and act, or ignore it and keep today's behaviour.
With routes.list, a hint decides the URL by itself and the snippet above becomes redundant — a declared route space is that decision already stated. See Declaring the route space.
One page, every surface
Resolving a context fires enrichProduct and one routes.product call per variant. A page that needs the head and JSON-LD and a breadcrumb should pay for that once — so ask for the page, not for each surface:
const page = await seo.productPage(product, { path: `/products/${slug}`, slug });
page.head(); // title, meta, canonical, .md alternate, JSON-LD
page.jsonLd(); // Product / ProductGroup on its own
page.markdown(); // the .md mirror
page.context; // for anything the package does not emitseo.categoryPage() is the same shape, with markdown(products).
The single-surface builders (seo.productHead, seo.productJsonLd, seo.productMarkdown) each resolve their own context, which is correct when you need exactly one and wasteful when you need two. There is no context-taking variant of the Next.js metadata builders on purpose: generateMetadata and the page component are separate invocations in the App Router, so a context resolved in one cannot reach the other.
Same-URL negotiation, and where it is not safe
Astro, SvelteKit and TanStack middleware can answer an HTML URL with Markdown when the request sends
Accept: text/markdown, because those frameworks let the middleware own Vary: Accept — the header
that keeps two representations at one URL cache-safe.
Next.js cannot. The App Router replaces Vary with its own RSC list on every HTML response,
discarding the header whether it came from middleware or from next.config headers(), on
prerendered, dynamic and 404 responses alike. createNextjsSeoProxy therefore does not negotiate on
HTML URLs; agents use the .md mirror, which carries its own Vary: Accept because this package
builds that response. Pass { negotiateHtml: true } only behind a cache you have configured to key
on Accept.
CMS-owned URLs
When public URLs are CMS page slugs rather than catalog slugs, state the pages as records and let
@commercengine/seo/routes answer every question about them:
import { createCmsRoutes } from "@commercengine/seo/routes";
export const routes = createCmsRoutes({
productBase: "/products",
source: () => fetchRouteRecordsFromCms(),
// [{ kind: "product", path: "/products/easy-to-rub-emulsion", productId: "P1", productSlug: "easy-to-rub-emulsion" },
// { kind: "product", path: "/products/knee-pain-relief-oil", productId: "P1", productSlug: "easy-to-rub-emulsion" },
// { kind: "category", path: "/category/relief", categoryId: "C1", categorySlug: "pain" }]
});A record inverts resolution. Rather than deriving a URL from an entity, it states that a path exists
and names the entity behind it — so several pages can declare one productId, and each surface gets
the answer it actually needs: the hint when rendering, the primary when linking, all of them when
enumerating. The result is a plain CommerceSeoRoutes: spread it and override any hook.
Construction rejects what resolution could not answer honestly — blank entity identifiers, a path
carrying a query or fragment, a path ending in the reserved .md mirror suffix, two records claiming
one path, a path outside its configured base or nested below it, and an entity with two primaries.
Paths are normalized first, so a trailing slash or a percent-encoded accent addresses the same record
either way.
Which page a link goes to
Ordinary links need exactly one route, and there is nothing to configure for it: the page the
catalog names wins — the record whose slug equals the entity's own productSlug / categorySlug.
A storefront on CMS-owned URLs is already keeping those in step, so the general page leads and the
keyword landing pages stay what they are, entry points rather than link targets.
| | Rule | When it applies |
|---|---|---|
| 1 | a record marked primary: true | you deliberately override the convention |
| 2 | the record the catalog names | the normal case |
| 3 | null — no destination, reported by conflicts() | nothing named a page |
Rule 3 is deliberately not "pick one". An entity nobody chose a page for has no destination a link
can honestly name, and productPath already returns null for a product with no page at all — so
callers render a non-link rather than sending a shopper somewhere chosen by sort(). Pass
ambiguous: "first" to get the lexically first route instead, which is a working link to a real
page for the right entity, at the cost of the destination being incidental. Either way it is a
decision, not an ordering.
Enumeration and inbound resolution ignore this entirely: an ambiguous entity keeps every one of its pages in sitemaps and static generation.
for (const { id, paths } of manifest.conflicts()) {
console.warn(`[routes] ${id} has ${paths.length} pages and the catalog names none of them:`, paths);
}conflicts() is computed at construction, from the records alone — which leaves one case it
cannot see. Where one record identifies a category only by ID and another only by slug, nothing in
the records says the two describe one category; the catalog object supplies that relationship at
lookup time, and only then can the combined group turn out to have no primary. That category
resolves to null for links, correctly, and never appears in conflicts().
So a build-time gate should assert on resolution as well as on the list, which also covers categories that have no page at all:
const undecided = categories.filter((category) => !manifest.categoryPath(category));
if (undecided.length) throw new Error(`[routes] no linkable page: ${undecided.map((c) => c.slug).join(", ")}`);A record needs productSlug for rule 2 to apply at build time. Where records come from a CMS read
alone and do not carry it, a caller holding a catalog item supplies it instead —
manifest.productPath(item) reaches the same answer, since the item carries product_slug.
The method accepts the actual Commerce Engine Item, Product, and ProductDetail types; it does
not accept an arbitrary { id, slug } object, so passing a Category is a type error and resolves
to null at runtime too.
Category IDs and slugs are separate namespaces: a bare string that is one category's ID and
another's slug resolves to neither. When one record identifies a category only by ID and another
only by slug, pass the catalog category object to categoryPath / categoryRoutes; its { id, slug }
pair supplies the relationship and both records participate in resolution and discovery.
Visual-editing CMSs: DatoCMS, Sanity and Contentful embed invisible Unicode (stega) in every string in draft mode. A slug that looks identical is a different string to
===and a different key in every index built from it, so strip it where you build the records —vercelStegaClean(value)— or every lookup misses and every URL becomesnull.
Links in the browser
createCmsRoutes is asynchronous because a CMS read is. A React link is not: it is rendered, not
awaited. So the manifest is a separate, synchronous constructor over records the component
already has:
import { createCommerceRouteManifest } from "@commercengine/seo/routes";
const manifest = createCommerceRouteManifest(await loadRecords()); // once, at the boundary
<a href={manifest.productPath(item) ?? undefined}>{item.product_name}</a>That is the convenience shape for route data already present in a small storefront or page payload;
it is not an instruction to download a 100,000-product route table into every browser. At that size,
resolve the handful of visible items in the storefront's own data layer and pass their hrefs to
components, or back routes.product with a storefront-owned point cache. The package makes no route
API calls and imposes no transport — loading, batching and cache freshness stay with the application
that owns the CMS.
This is what keeps one URL space. Without it a storefront ends up with two: the crawler and the agent
resolving CMS slugs through the package, and product cards building /products/<catalog-slug> by
hand — which 404s the moment the two differ.
productPath returns null for a product with no public page. Render a non-link; never fall back to
a catalog slug, since that is precisely the URL that does not exist.
| Method | Answers |
|---|---|
| productPath / categoryPath | the primary route — what a link needs |
| productRoutes / categoryRoutes | every route, primary first — what discovery needs |
| resolve(path) | the record at a full path |
| resolveProduct / resolveCategory | a path or a bare segment, when the kind is known |
| conflicts() | entities whose several routes nothing settled — no primary, none named by the catalog |
| toJSON() | the normalized records, ready to serialize to a browser |
resolve() deliberately refuses a bare segment: wellness can be both /products/wellness and
/category/wellness, and those two paths never collide, so nothing else can catch the ambiguity.
Enumeration, and where it applies
routes.productRoutes and routes.categoryRoutes return every public route for an entity.
Discovery uses them; nothing else does:
| Surface | Routes used |
|---|---|
| sitemap.xml, /sitemap.md, static generation | every route |
| canonical, .md mirror, metadata | the route being rendered |
| category rows, search results, @commercengine/ai | the primary route |
Both hooks default to the single-route answer, so a storefront with one page per product configures nothing. Static generation renders each route — its own context, hint, document and canonical — rather than listing routes it has not published; six sitemap entries backed by one file would be five broken mirrors. Sitemap shards are packed from expanded URLs, so a catalog whose products expand several-fold cannot produce a shard above the 50,000-URL limit.
When the catalog is not the route space
Both hooks answer per entity, so they can only be asked about entities a catalog walk reaches. A
page whose product is not in listProducts — delisted, inactive, a bundle you still sell copy for —
is then unreachable by discovery however well the manifest knows it.
routes.list declares the whole space at once, and discovery reads it instead of walking.
createCmsRoutes supplies it from the same cached manifest its resolvers use, so there is nothing to
wire up:
routes: createCmsRoutes({ source: loadRecords }) // `list` includedsitemap.xml, /sitemap.md and static generation all switch over together. Shards then pack against
real URLs without each re-walking the catalog, and static generation fetches each entity by the
identifier its record carries — skipping, rather than failing the build, where the catalog will not
return one. lastModified on a record feeds sitemap.xml, per page rather than per product.
Records reaching discovery go through the same validation as records reaching a manifest: two records claiming one path, or a path outside its base, is rejected rather than cleaned up. A sitemap is the surface where being wrong is hardest to notice.
markdown: false says the page exists but its mirror does not. A .md document is rendered from
the catalog entity, so a page whose product has been delisted still serves HTML and still belongs in
sitemap.xml, while /that-page.md has nothing to render. Set it once and every surface stops
advertising the mirror, including the two a page renders itself:
| Surface | With markdown: false | How it learns |
|---|---|---|
| /sitemap.md | links the page itself, not a .md | reads the record |
| Static generation | writes no file; the page stays in sitemap.xml | reads the record |
| Request handler /that-page.md | 404, without touching the catalog | reads the record |
| Runtime /llms.txt | omits a category with no renderable mirror; otherwise links its first ranked renderable route | createCmsRoutes.categoryMarkdownRoute |
| productHead / categoryHead | no <link rel="alternate" type="text/markdown"> | the hint |
| createProductMetadata / createCategoryMetadata | no alternates.types | the hint |
| productMarkdown / categoryMarkdown frontmatter | no markdown_url | the hint |
The first four run where the route adapter already has the route space in hand. The last three run while rendering one page, where it is not — so the fact travels on the hint.
Pass it from the record you already fetched for this render. A CMS-fronted page loads its own
page document to render anything at all, and that document is where markdown came from:
const page = await cms.getProductPage(slug); // already needed for title, copy, blocks
return createProductMetadata(seo, product, {
path: `/products/${slug}`,
slug,
markdown: Boolean(page.ceItemReference?.productId), // whatever your record means by "has a mirror"
});That costs nothing: the value rides along on a fetch the render was making regardless.
seo.productMarkdownAvailable(slug) answers the same question from the declared route space, and is
the right tool when a manifest is already in hand — a long-lived server, a build, a warm isolate.
It is not the default per-page recipe: it calls routes.list(), so on a cold serverless isolate with
100k records it fetches and indexes the complete route space to return one boolean. Resolving it
inside every render context would do that implicitly, on every page; this at least makes the cost
visible, but the cheapest correct answer is the one your CMS record already gave you.
Omit markdown entirely and the mirror is advertised, which is right for the ordinary case where
every page has one. context.markdownUrl is undefined when it is false, and that absence is what
every renderer keys off.
Leave it unset for a CE-native storefront: there the catalog is the route space, and walking it is both correct and cheaper than materializing every record.
Hand back a manifest if you have one. routes.list may return either CommerceRouteRecord[] or
a built CommerceRouteManifest. Records are re-indexed on every call — unnoticeable for a hundred
routes, ~170ms of CPU per .md request for a hundred thousand — because how long a CMS read stays
valid is the adapter's decision, not one this package can make for it. A manifest is passed through
untouched, and is also the only form that carries ambiguous and variantPath across.
createCmsRoutes already does this. A prebuilt manifest must use the same productBase and
categoryBase as the SEO instance; a mismatch throws rather than publishing outbound URLs the
request matcher cannot serve.
/sitemap.md is bounded, and says so when it truncates. It lists up to sitemapLimit entries
(default 1000). With routes.list the limit counts route records of both kinds and the notice
reads N of M routes; without it, it counts catalog products walked and says so in those terms,
because the entry count is a different number entirely — categories are listed too, and one product
can have several routes. The notice names sitemap.xml only when the caller says it is served
(sitemapXml: true, on by default in the Next.js handler), so it never points an agent at a route
nobody mounted.
With routes.list set, a hint also decides the URL without a custom resolver — the storefront has
enumerated its pages, so re-deriving /products/<catalog-slug> while rendering one it declared would
contradict the declaration. Without it, a hint stays advisory, as below.
When a CMS owns the copy
createProductMetadata replaces title, description and Open Graph, which is right for a storefront
where Commerce Engine owns them and wrong for one where an editor does. withCommerceMetadata adds
only what the package knows and the CMS cannot:
const page = await seo.productPage(product, { path, slug });
return withCommerceMetadata(seo, buildMetadataFromCms(cms.seo), page.context);That merges alternates.canonical, the text/markdown alternate, and robots: noindex on any
deployment that is not proven production — merging rather than spreading, because alternates is
nested and a spread silently drops whatever the CMS put in it. A canonical the CMS set explicitly
still wins.
Open Graph's commerce block (og:type: product, product:price:*) cannot be expressed in Next's
Metadata type at all. Render it in the page body:
{productOpenGraphTags(product).map((tag) => <meta key={tag.property} {...tag} />)}Reconciling with editor-authored JSON-LD
A CMS field holding a hand-authored Product block cannot be deleted the way a hand-rolled template
schema can — an editor owns it. schemaTypes and schemaCollides report the overlap so you can
decide which one renders:
const blocks = cmsBlocks.filter((block) => !schemaCollides(block.schemaJson, generated));Product and ProductGroup count as one entity, and @graph wrappers are walked. The generated
block is usually the safer keep: an authored one carries price and availability frozen at authoring
time, and those are what a crawler cross-checks against the page.
Pages that are not catalog records
A CMS-fronted storefront resolves a category route to a name and a page slug, never to a Category. Rather than fabricating one to satisfy a signature, state the page:
const context = seo.pageContext({ path: `/category/${slug}`, title: name });
const schema = categoryJsonLd({ name, description }, context);pageContext also takes canonicalPath for a route that is an alias of another. It returns a
PageRenderContext — the same shape a category context has, named for the page because it describes
both.
Skipping per-variant resolution
Where per-variant resolution is itself a network call, hand the map over and it is skipped entirely:
enrichProduct: async (product) => ({
variantUrls: await cms.variantUrls(product.id), // replaces resolution; null omits a variant
}),Omitting an entity
Return null from a route resolver to keep an entity out of public schema, Markdown, and sitemaps. Omission is a configuration choice, not an error, but listing an entity and being an entity's page are different situations, so surfaces honour it differently:
| Surface | routes.* → null |
|---|---|
| Sitemaps (.xml and .md), category listings, llms.txt | Entry omitted |
| Static generation | Asset skipped, build continues |
| Request handler and framework middleware | 404 |
| createProductMetadata / createCategoryMetadata | noindex metadata |
| productContext / productHead / productJsonLd / productMarkdown called directly | Throws CommerceSeoRouteError |
The last row is deliberate: a page component resolving its own context must decide what an unrouted entity means for that page — a 404, a redirect, or a noindex render — and the package raises rather than inventing a URL.
Validation
pnpm --filter @commercengine/seo typecheck
pnpm --filter @commercengine/seo test:coverage
pnpm --filter @commercengine/seo check-exports
pnpm --filter @commercengine/seo test:livetest:live reads the existing packages/e2e-tests/.env and validates real catalog output without printing credentials.
Type policy
The framework-neutral core has no framework dependency of any kind: structured data, Markdown, discovery documents, robots policy, XML sitemaps, and the request handler are all built from canonical SDK types and Web standards. Framework packages are adapters that restate core results in framework types (@commercengine/seo/nextjs maps to Metadata/MetadataRoute) or mount core handlers on framework routes. Nothing in src/ outside an adapter directory imports a framework.
Canonical generated commerce entities (ProductDetail, Product, Item, Category, ProductCategory, Variant, and related types) are imported from @commercengine/storefront, the required peer. That indirection is load-bearing rather than stylistic: the umbrella pins @commercengine/storefront-sdk to an exact version, so importing the SDK directly under an independent range puts two copies of it in the dependency tree and framework storefronts stop being structurally assignable. One copy keeps NextjsStorefront and its siblings assignable with no casts anywhere in this package. @commercengine/storefront-sdk remains an optional peer for applications wiring the canonical StorefrontFactory directly.
Framework wrappers are accepted structurally through publicStorefront(), and the canonical StorefrontFactory through public(). Types authored here model only package concerns that the API does not: site identity, public route resolution, presentation enrichment, and framework integration.
