Skip to content

feat(seo): SEO primitives for core: JSON-LD, canonical URLs, hreflang, sitemap, and robots.txt - #4113

Open
div-cowboy wants to merge 3 commits into
Shopify:previewfrom
div-cowboy:feat/seo-primitives
Open

div-cowboy wants to merge 3 commits into
Shopify:previewfrom
div-cowboy:feat/seo-primitives

Conversation

@div-cowboy

@div-cowboy div-cowboy commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: The new core ships nothing for SEO. Every storefront needs canonical URLs, structured data, hreflang, a sitemap, and a robots.txt, and today each template and every generated app re-writes that glue by hand. This PR adds an seo module to @shopify/hydrogen that covers all of it as framework-agnostic primitives and request handlers, migrates both templates onto it, and teaches the packaged skills to use it.

Why this belongs in core

The rebuild's own conventions already require this, but nothing in the library provides it:

This is the same shape as formatMoney(): glue that only Shopify can define correctly, that every framework needs, and that agents currently reinvent per project. Structured data and sitemaps are also how AI shopping crawlers discover products, and the generated robots.txt advertises the UCP/MCP endpoints Hydrogen already serves, which fits the "built for agents" goal.

I checked open PRs and upstream branches for overlap. Nothing in flight touches sitemaps, robots, or JSON-LD in core. Adjacent work to be aware of: #4009 (global i18n config, which could later feed locales), #3976 (/.well-known/ucp proxy, which could be added to the robots header once merged), #3990 (touches the same React Router routes), and #4023 (its sitemap note should point at the handlers once both land).

Before

// templates/react-router/app/routes/collection.tsx (and two near-copies)
function BreadcrumbJsonLd({ collection, origin }) {
  const jsonLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [/* … */] };
  return <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>;
}
// templates/nextjs/app/sitemap.ts — hand-rolled, capped at 250 products, no pages or blogs
const { data } = await staticStorefrontClient.graphql(SITEMAP_QUERY);

After

import {
  createProductJsonLd,
  createRobotsTxtServerHandlers,
  createSitemapServerHandlers,
  getCanonicalUrl,
  serializeJsonLd,
} from "@shopify/hydrogen";

// Request handlers, registered like the cart handlers
const sitemapHandlers = createSitemapServerHandlers({ origin, routeTemplates, staticPaths: ["/", "/collections"] });
const robotsHandlers = createRobotsTxtServerHandlers({ origin, routeTemplates });
handleShopifyRoutes({ request, requestContext, sessionManager, storefrontClient, handlers: [cartHandlers, sitemapHandlers, robotsHandlers] });
// -> GET /sitemap.xml, GET /sitemap/:type/:page.xml, GET /robots.txt

// Page-level primitives
const url = getCanonicalUrl(request.url, { origin }); // ?Color=Red, filters, cursors, utm_* dropped
const jsonLd = createProductJsonLd(product, { url, selectedVariant });
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />

What this changes

Structured data and URLs (packages/hydrogen/src/core/seo/)

  • serializeJsonLd() escapes <, >, &, U+2028, U+2029 as \u sequences so a description containing </script> cannot end the element. Round-trips through JSON.parse.
  • createProductJsonLd() builds a Product node with one Offer per entry in variants, a single Offer for selectedVariant, or an AggregateOffer from priceRange. The selected variant's image is moved to the front. Input types mirror ProductInput in core/product/state.ts: minimum fields only, wider query results pass through.
  • createBreadcrumbJsonLd() and createOrganizationJsonLd().
  • getCanonicalUrl() drops every query param not in keepSearchParams, strips the hash, normalizes the trailing slash (trailingSlash option), and replaces the request origin with a trusted origin.
  • getLanguageAlternates() swaps the current i18n pathPrefix for each locale's prefix using the existing stripI18nPathPrefix / prependPathPrefix helpers, and emits x-default.

Sitemap and robots handlers (packages/hydrogen/src/core/seo/sitemap/)

  • createSitemapServerHandlers() returns an index handler (/sitemap.xml) and a page handler (/sitemap/:type/:page.xml). The index lists one child per Storefront API pagesCount page for products, collections, pages, and blogs by default, plus a static child for staticPaths and any additionalSitemaps. Pages render <loc> and <lastmod>; URLs come from getStandardRoute() so they follow routeTemplates. With locales, every URL is repeated per locale with xhtml:link alternates (and x-default); without, URLs use the request's i18n pathPrefix. getResourceUrl can override or drop any resource; getChangeFrequency adds <changefreq>. Empty pages fall back to the homepage so Search Console never sees an empty file. Unknown types and invalid pages return 404; Storefront API failures return 503 with no-store. Both handlers set Cache-Control from the cache strategy (default Cache.long()) and apply the same strategy to the Storefront API queries through the client's existing withStorefrontClientCache, so cache-enabled clients get cached sitemap queries and cache-less clients keep working.
  • articles and metaobjects are opt-in. Articles require getResourceUrl, because the Storefront API sitemap returns article handles without their blog handle (the classic helper emitted /articles/:handle, which 404s; see Generated sitemap.xml weirdness #2777). Metaobjects default to /<onlineStoreUrlHandle>/<handle>.
  • createRobotsTxt() and createRobotsTxtServerHandlers() produce Shopify's default Online Store crawl rules trimmed to what a headless app serves: everything public allowed; admin, cart, checkout, orders, account (with /account/login allowed), /api/, /__shopify/, /graphiql, and filter/sort/preview crawl traps disallowed; the essentials repeated for adsbot-google; a Sitemap: line; and header comments pointing agents at /api/ucp/mcp and /api/mcp. Cart, search, collection, and blog paths derive from routeTemplates. disallow, allow, additionalGroups, locales (the same list the sitemap takes; repeats rules under a /* locale prefix), and agents: false customize it.
  • Storefront API queries live in queries.ts as gql() documents validated by hydrogen gql check. The index uses one aliased query for all six counts; pages use sitemap(type: $type) with a typed enum variable.

Route templates and registered handlers

  • New standard-routes/route-template.ts holds the one definition of the :param template syntax (param regex, compile to RegExp, match with decoding, interpolate). Standard-route matching and building, the registered-handler matcher, and the sitemap handlers all use it instead of their own copies.
  • Handler pathnames may now be templates such as /sitemap/:type/:page.xml. Each :param captures one segment (or the part before a literal suffix), is URL-decoded, and arrives as context.params. Templates compile once per process; matching is a single pass where literal pathnames win. ShopifyRouteHandlerContext.params is always present (empty for literal handlers); HydrogenRoutesOptions is the base context without it, so handleShopifyRoutes callers are unchanged.
  • getResponseCacheControlHeader() joins getPlatformCacheControlHeader() in core/cache/strategies.ts for browser and CDN headers that keep stale directives separate.
  • Handlers can return { type: "response", response } to serve non-JSON bodies. Request-context response headers are still applied on top.

Templates

  • React Router: new app/lib/seo.ts (getSiteOrigin, getPageCanonicalUrl, canonicalLink, and memoized sitemap/robots handlers) and app/components/JsonLd.tsx (JsonLdScript, shared BreadcrumbJsonLd). Root middleware registers the handlers; the root layout emits Organization JSON-LD from shop.name and the brand logo. The three route-local breadcrumb copies are replaced with the shared component. Every catalog loader computes its canonical URL and site origin once, so meta() and breadcrumb JSON-LD read one value; the search canonical keeps q. PUBLIC_SITE_ORIGIN is documented in .env.example.
  • Next.js: proxy.ts registers the sitemap and robots handlers with SITE_ORIGIN; app/sitemap.ts, app/robots.ts, SITEMAP_QUERY, and jsonLdScript() are removed; ProductDetails uses createProductJsonLd() + serializeJsonLd().

Skills and docs

  • New hydrogen-seo packaged skill covering canonical URLs, hreflang, JSON-LD, sitemap, and robots, listed in the README skills table.
  • hydrogen-request-handlers skill lists the two handler groups, explains template pathnames and response results, and adds verify steps for /sitemap.xml and /robots.txt.

Tests: 27 for JSON-LD and canonical helpers, 18 for the sitemap and robots handlers (index, pages, locales, metaobjects, articles, static, 404/503, caching), 5 for createRobotsTxt, 4 for XML rendering, 7 for the route-template primitive, 5 for registered-handler params and raw responses. Full package suite: 149 files, 2352 tests.

Developer impact

Includes a minor changeset for @shopify/hydrogen. Nine new function exports, their option and result types, two new route-handler capabilities, and a new packaged skill. Existing apps are unaffected until they register the handlers or import the helpers.

ShopifyRouteHandlerContext gains a required params field. Only code that constructs that context by hand (rather than receiving it from handleShopifyRoutes) needs to add params: {}. The handleShopifyRoutes options type is unchanged.

getCanonicalUrl() defaults to stripping every query param. Search pages that want q in the canonical pass keepSearchParams: ["q"].

SitemapResource is a discriminated union on type, so getResourceUrl callbacks can read metaobjectType and onlineStoreUrlHandle only after narrowing to "metaobjects".

UX impact

  • Both templates now serve /sitemap.xml, /sitemap/{type}/{page}.xml, and /robots.txt from Hydrogen. The Next.js sitemap now covers pages and blogs and paginates instead of stopping at 250 products.
  • React Router product and collection pages now use the product or collection title in <title> and description instead of the generic "Product" / "Collection", and every catalog page carries a canonical link.
  • Product pages in both templates emit Offer data with sku, productID, brand, and category where the query provides them.

Out of scope

  • Meta tag generation (title, description, Open Graph, Twitter). Each framework has a native metadata API; these helpers feed it.
  • An image sitemap extension. The Storefront API returns a relative filepath whose CDN mapping is not documented, so images are left out rather than guessed.
  • Locale wiring in the templates. Both are single-market today; locales is supported by the handlers and documented in the skill.
  • Updating the migration skill's sitemap note, which lives in the still-open Add migrate-to-hydrogen-2026-10 skill #4023.

Risk

  • The registered-routes matcher is the shared path for cart, predictive search, and customer account handlers. Literal matches still win and short-circuit; template matching only runs for handlers whose pathname contains a :param, with the compiled pattern memoized. standard-routes/match.ts and build.ts now call the shared primitive, which is behavior-preserving but touches the standard-route redirect path. Covered by the new route-template.test.ts, registered-routes.test.ts, and the existing standard-route and handle-shopify-routes suites.
  • Next.js proxy.ts now returns XML and text bodies from middleware. The cart handlers already return JSON bodies the same way, so this is the same mechanism with a different content type.
  • createProductJsonLd emits @id equal to url. A store rendering the same product under a collection-scoped route should pass the canonical product URL for both.
  • Naming of the option and input types is the main thing to review.

How to Test

  1. Run pnpm install && pnpm --filter @shopify/hydrogen build.
  2. Run MOCK_SHOP=1 pnpm --filter @shopify/hydrogen-template-react-router dev and note the port.
  3. Open /robots.txt. Confirm User-agent: *, the disallow list, an adsbot-google group, a Sitemap: line with the app origin, and the UCP/MCP endpoint comments at the top.
  4. Open /sitemap.xml. Confirm a <sitemapindex> with products, collections, pages, blogs, and static children.
  5. Open /sitemap/products/1.xml. Confirm a <urlset> with one <url> per mock.shop product (29 for the default store), each with <lastmod>, and Cache-Control: public, max-age=3600, stale-while-revalidate=82800.
  6. Open /sitemap/videos/1.xml and confirm a 404 with Cache-Control: no-store.
  7. Open /products/beanie?Color=Red&utm_source=x and view source. Confirm <link rel="canonical" href="…/products/beanie"> with no query string, an Organization JSON-LD block, and a Product JSON-LD block whose offers is a single Offer with the selected variant's price and InStock.
  8. Open /collections and confirm a BreadcrumbList JSON-LD block with Home and Collections.
  9. Paste the product page source into https://validator.schema.org and confirm Product, Offer, and Organization parse with no errors.
  10. Run pnpm --filter @shopify/hydrogen-template-nextjs dev, then repeat steps 3 to 5 on the Next.js port; confirm /sitemap.xml is served by Hydrogen (the static child lists /, /collections, and /search).

🤖 Generated with Claude Code

Adds a framework-agnostic `core/seo` module with schema.org builders
(Product, BreadcrumbList, Organization), a script-safe JSON-LD serializer,
a canonical URL helper that strips variant/filter/tracking params and swaps
in a trusted origin, and an hreflang alternates builder driven by the same
locale prefixes as `I18nConfig`.

Ships a `hydrogen-seo` packaged skill and a minor changeset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds `createSitemapServerHandlers()` (index + paginated child sitemaps
backed by the Storefront API `sitemap` query, route-template-aware URLs,
per-locale entries with xhtml:link alternates, static paths, 503/404
handling) and `createRobotsTxtServerHandlers()` / `createRobotsTxt()`
(Shopify's default crawl rules derived from route templates, adsbot group,
sitemap line, UCP/MCP endpoint comments for agents).

Registered route handlers can now use template pathnames such as
`/sitemap/:type/:page.xml` (captured as `context.params`) and return
`{ type: "response", response }` for non-JSON bodies.

Migrates the React Router and Next.js templates onto the helpers: shared
JSON-LD components, Product and Organization structured data, canonical
links, and Hydrogen-served sitemap/robots routes in place of hand-rolled
copies. Updates the hydrogen-seo and hydrogen-request-handlers skills.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@div-cowboy div-cowboy changed the title feat(seo): SEO primitives for core: JSON-LD, canonical URLs, hreflang (proposal + phase 1) feat(seo): SEO primitives for core: JSON-LD, canonical URLs, hreflang, sitemap, and robots.txt Sep 29, 2026
@div-cowboy
div-cowboy marked this pull request as ready for review September 29, 2026 21:21
@div-cowboy
div-cowboy requested a review from a team as a code owner September 29, 2026 21:21
…ng builder

Extracts one `standard-routes/route-template.ts` primitive (param regex,
compile, match, interpolate) used by standard routes, registered route
handlers, and the sitemap handlers, so the `:param` syntax is defined once.
The handler matcher becomes a single memoized pass.

Sitemap queries now use the client's `withStorefrontClientCache`, so the
`cache` strategy (default `Cache.long()`) applies to both the response header
and, on cache-enabled clients, the queries. Adds
`getResponseCacheControlHeader` beside the platform serializer.

Also: one `buildLanguageAlternates` for `<link rel=alternate>` and sitemap
`xhtml:link` output, a discriminated `SitemapResource` type, robots.txt
driven by the same `locales` list as the sitemap, a memoized robots body,
and one `getSiteOrigin` / loader-computed canonical URL in the React Router
template.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant