Repository navigation
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
Open
div-cowboy wants to merge 3 commits into
div-cowboy wants to merge 3 commits into
Conversation
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
marked this pull request as ready for review
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anseomodule to@shopify/hydrogenthat 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:
examples/core/notes/engineering.mdF10 says every generated storefront includes canonicals, Product JSON-LD with offer data, and sitemap/robots routes. F6 requires a single escaping helper for JSON-LD. F13 says JSON-LD helpers must not be duplicated per page.BreadcrumbJsonLdfunction, each renderingJSON.stringifyas a React child of<script>, and no sitemap or robots route. The Next.js template carried its ownjsonLdScript(), arobots.ts, and asitemap.tsthat could only ever list the first 250 products.references/sitemap-and-seo.md) tells merchants thatgetSitemap/getSitemapIndexare gone and to write a local sitemap file.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 generatedrobots.txtadvertises 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/ucpproxy, 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
After
What this changes
Structured data and URLs (
packages/hydrogen/src/core/seo/)serializeJsonLd()escapes<,>,&, U+2028, U+2029 as\usequences so a description containing</script>cannot end the element. Round-trips throughJSON.parse.createProductJsonLd()builds aProductnode with oneOfferper entry invariants, a singleOfferforselectedVariant, or anAggregateOfferfrompriceRange. The selected variant's image is moved to the front. Input types mirrorProductInputincore/product/state.ts: minimum fields only, wider query results pass through.createBreadcrumbJsonLd()andcreateOrganizationJsonLd().getCanonicalUrl()drops every query param not inkeepSearchParams, strips the hash, normalizes the trailing slash (trailingSlashoption), and replaces the request origin with a trustedorigin.getLanguageAlternates()swaps the current i18npathPrefixfor each locale's prefix using the existingstripI18nPathPrefix/prependPathPrefixhelpers, and emitsx-default.Sitemap and robots handlers (
packages/hydrogen/src/core/seo/sitemap/)createSitemapServerHandlers()returns anindexhandler (/sitemap.xml) and apagehandler (/sitemap/:type/:page.xml). The index lists one child per Storefront APIpagesCountpage for products, collections, pages, and blogs by default, plus astaticchild forstaticPathsand anyadditionalSitemaps. Pages render<loc>and<lastmod>; URLs come fromgetStandardRoute()so they followrouteTemplates. Withlocales, every URL is repeated per locale withxhtml:linkalternates (andx-default); without, URLs use the request's i18npathPrefix.getResourceUrlcan override or drop any resource;getChangeFrequencyadds<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 withno-store. Both handlers setCache-Controlfrom thecachestrategy (defaultCache.long()) and apply the same strategy to the Storefront API queries through the client's existingwithStorefrontClientCache, so cache-enabled clients get cached sitemap queries and cache-less clients keep working.articlesandmetaobjectsare opt-in. Articles requiregetResourceUrl, 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()andcreateRobotsTxtServerHandlers()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/loginallowed),/api/,/__shopify/,/graphiql, and filter/sort/preview crawl traps disallowed; the essentials repeated foradsbot-google; aSitemap:line; and header comments pointing agents at/api/ucp/mcpand/api/mcp. Cart, search, collection, and blog paths derive fromrouteTemplates.disallow,allow,additionalGroups,locales(the same list the sitemap takes; repeats rules under a/*locale prefix), andagents: falsecustomize it.queries.tsasgql()documents validated byhydrogen gql check. The index uses one aliased query for all six counts; pages usesitemap(type: $type)with a typed enum variable.Route templates and registered handlers
standard-routes/route-template.tsholds the one definition of the:paramtemplate syntax (param regex, compile toRegExp, 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./sitemap/:type/:page.xml. Each:paramcaptures one segment (or the part before a literal suffix), is URL-decoded, and arrives ascontext.params. Templates compile once per process; matching is a single pass where literal pathnames win.ShopifyRouteHandlerContext.paramsis always present (empty for literal handlers);HydrogenRoutesOptionsis the base context without it, sohandleShopifyRoutescallers are unchanged.getResponseCacheControlHeader()joinsgetPlatformCacheControlHeader()incore/cache/strategies.tsfor browser and CDN headers that keep stale directives separate.{ type: "response", response }to serve non-JSON bodies. Request-context response headers are still applied on top.Templates
app/lib/seo.ts(getSiteOrigin,getPageCanonicalUrl,canonicalLink, and memoized sitemap/robots handlers) andapp/components/JsonLd.tsx(JsonLdScript, sharedBreadcrumbJsonLd). Root middleware registers the handlers; the root layout emits Organization JSON-LD fromshop.nameand 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, someta()and breadcrumb JSON-LD read one value; the search canonical keepsq.PUBLIC_SITE_ORIGINis documented in.env.example.proxy.tsregisters the sitemap and robots handlers withSITE_ORIGIN;app/sitemap.ts,app/robots.ts,SITEMAP_QUERY, andjsonLdScript()are removed;ProductDetailsusescreateProductJsonLd()+serializeJsonLd().Skills and docs
hydrogen-seopackaged skill covering canonical URLs, hreflang, JSON-LD, sitemap, and robots, listed in the README skills table.hydrogen-request-handlersskill lists the two handler groups, explains template pathnames andresponseresults, and adds verify steps for/sitemap.xmland/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.ShopifyRouteHandlerContextgains a requiredparamsfield. Only code that constructs that context by hand (rather than receiving it fromhandleShopifyRoutes) needs to addparams: {}. ThehandleShopifyRoutesoptions type is unchanged.getCanonicalUrl()defaults to stripping every query param. Search pages that wantqin the canonical passkeepSearchParams: ["q"].SitemapResourceis a discriminated union ontype, sogetResourceUrlcallbacks can readmetaobjectTypeandonlineStoreUrlHandleonly after narrowing to"metaobjects".UX impact
/sitemap.xml,/sitemap/{type}/{page}.xml, and/robots.txtfrom Hydrogen. The Next.js sitemap now covers pages and blogs and paginates instead of stopping at 250 products.<title>and description instead of the generic "Product" / "Collection", and every catalog page carries a canonical link.Offerdata withsku,productID,brand, andcategorywhere the query provides them.Out of scope
title,description, Open Graph, Twitter). Each framework has a native metadata API; these helpers feed it.filepathwhose CDN mapping is not documented, so images are left out rather than guessed.localesis supported by the handlers and documented in the skill.Risk
:param, with the compiled pattern memoized.standard-routes/match.tsandbuild.tsnow call the shared primitive, which is behavior-preserving but touches the standard-route redirect path. Covered by the newroute-template.test.ts,registered-routes.test.ts, and the existing standard-route andhandle-shopify-routessuites.proxy.tsnow 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.createProductJsonLdemits@idequal tourl. A store rendering the same product under a collection-scoped route should pass the canonical product URL for both.How to Test
pnpm install && pnpm --filter @shopify/hydrogen build.MOCK_SHOP=1 pnpm --filter @shopify/hydrogen-template-react-router devand note the port./robots.txt. ConfirmUser-agent: *, the disallow list, anadsbot-googlegroup, aSitemap:line with the app origin, and the UCP/MCP endpoint comments at the top./sitemap.xml. Confirm a<sitemapindex>withproducts,collections,pages,blogs, andstaticchildren./sitemap/products/1.xml. Confirm a<urlset>with one<url>per mock.shop product (29 for the default store), each with<lastmod>, andCache-Control: public, max-age=3600, stale-while-revalidate=82800./sitemap/videos/1.xmland confirm a 404 withCache-Control: no-store./products/beanie?Color=Red&utm_source=xand view source. Confirm<link rel="canonical" href="…/products/beanie">with no query string, anOrganizationJSON-LD block, and aProductJSON-LD block whoseoffersis a singleOfferwith the selected variant's price andInStock./collectionsand confirm aBreadcrumbListJSON-LD block withHomeandCollections.pnpm --filter @shopify/hydrogen-template-nextjs dev, then repeat steps 3 to 5 on the Next.js port; confirm/sitemap.xmlis served by Hydrogen (the static child lists/,/collections, and/search).🤖 Generated with Claude Code