feat(hydrogen): resolve inbound ?variant=<id> deep links - #3898
Open
div-cowboy wants to merge 1 commit into
Open
feat(hydrogen): resolve inbound ?variant=<id> deep links#3898div-cowboy wants to merge 1 commit into
?variant=<id> deep links#3898div-cowboy wants to merge 1 commit into
Conversation
Shopify's own surfaces — Liquid storefronts, Shopping feeds, email campaigns, paid ads, and Shop Pay — deep-link to a product with a bare variant id (`?variant=41565182099480`) rather than one param per option. Hydrogen product pages only read option params (`?Color=Red&Size=M`), so those links silently resolve to the default variant instead of the one the shopper clicked. Add two exports: - `getVariantIdParam` normalizes the param to a ProductVariant GID, returning null for anything else — including GIDs for other resource types, so an untrusted param can't reach a `node(id:)` lookup. Built on the existing `parseGid`, matching how `shop-pay.ts` validates ids. - `handleVariantDeepLink` resolves the variant and returns a 307 Response, or null when the request isn't a product URL with a resolvable variant. It mirrors the `handleUrlRedirects` interceptor shape, so it drops into any framework's pre-render hook. Resolution goes through `routeTemplates`, so apps serving products from a custom path (`/p/:productHandle`) and apps with an i18n `pathPrefix` (`/en-ca/products/...`) work with no extra configuration. The redirect preserves `utm_*` and other campaign params, and follows the variant's own product handle so a combined-listing variant lands on the right page. Wired into examples/nextjs, examples/react-router, and examples/hydrogen. This is additive. Option params stay canonical and stay the no-JS mechanism (F4); no existing URL contract changes. Unresolvable or stale ids return null and render the default variant rather than 404. Run it before the route renders, not after a 404 like `handleShopifyRedirects` — the product route exists, and only the variant selection needs translating. A redirect issued during rendering can degrade to a client-side one: measured in the Next.js example at 53KB with no add-to-cart, versus 80KB at the canonical URL, which would leave a no-JS shopper following an ad link on an unusable page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
div-cowboy
force-pushed
the
feat/variant-id-deep-links
branch
from
August 1, 2026 17:10
673b422 to
b86620f
Compare
Contributor
|
explored an alternative direction here #3952 can you give me your feedback? |
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: Shopify's own surfaces deep-link to products with a bare variant id (
?variant=41565182099480). Hydrogen product pages only read option params (?Color=Red&Size=M), so those links silently render the default variant instead of the one the shopper clicked. This adds a framework-agnostic interceptor that resolves the param, and wires it into the three examples.Liquid storefronts, Shopping feeds, email campaigns, paid ads, and Shop Pay all emit
?variant=<id>URLs. When a merchant migrates to Hydrogen, every one of those existing links breaks quietly — no error, no 404, just the wrong variant across their whole paid-acquisition surface.Before
An inbound link from a Shopping feed or ad:
The
variantparam is not an option name, sogetSelectedProductOptionsignores it and the Storefront query falls back toselectedOrFirstAvailableVariant.After
The shopper lands on the variant the link pointed at, and the URL matches what the variant picker itself would have produced.
What this changes
Two new exports from
@shopify/hydrogen:getVariantIdParam({ searchParams })— reads thevariantparam and normalizes it to aProductVariantGID, accepting both the bare legacy id Liquid emits and a full GID. Returnsnullfor anything that is not a product variant id, including GIDs for other resource types, so an untrusted param cannot be forwarded into anode(id:)lookup for an unrelated object. Built on the existingparseGid, matching howshop-pay.tsalready validates variant ids.handleVariantDeepLink({ request, storefrontClient, routeTemplates, pathPrefix })— resolves the variant and returns a 307Response, ornullwhen the request is not a product URL with a resolvable variant. Mirrors thehandleUrlRedirectsinterceptor shape.Because resolution goes through
routeTemplates, this works with no extra configuration for apps serving products from a custom path (/p/:productHandle) and apps using an i18npathPrefix(/en-ca/products/…). The redirect preserves unrelated params (utm_*,ref) so campaign attribution survives, follows the variant's own product handle so a combined-listing variant lands on the right page, and skips Shopify'sTitle/Default Titlesentinel so single-variant products do not pick up a meaningless param.Wired into
examples/nextjs(inproxy.ts),examples/react-router(in the storefront middleware), andexamples/hydrogen(inserver.ts).This is additive. Option params remain the canonical URL contract and remain the no-JS mechanism described in
engineering.md§F4. No existing URL behavior changes, and the canonical tag is untouched — variant params still do not affect it (§F10).Why it runs before rendering, not after a 404
handleShopifyRedirectsis wired post-404 everywhere, which is right for admin-configured Storefront URL redirects: those only apply when the route does not exist. This is a different case — the product route exists and returns 200, and only the variant selection needs translating.It also has to beat rendering. A page-level
redirect()looks like it works, but under Next.js Cache Components the shell streams before the redirect can set a status, so it degrades to a client-side redirect. Measured in the Next.js example: the?variant=URL served 53KB with no add-to-cart, versus 80KB at the canonical URL. A shopper with JavaScript disabled following an ad link would land on an unusable page — breaking the very progressive-enhancement requirement this change is meant to respect. Redirecting ahead of framework routing keeps it a real HTTP redirect for everyone.Developer impact
Includes a minor changeset for
@shopify/hydrogen. Both exports are additive; nothing existing changes signature or behavior.One framework note worth knowing when adopting this: Hydrogen returns a relative
location, which is what a framework router wants, but Next.js's proxy resolves the header withnew URL()and throws on a relative value. The Next.js example rebuilds it against the request origin — seeexamples/nextjs/proxy.ts.UX impact
Shoppers arriving from a Shopify-generated link now see the variant that link referred to, with its correct price, image, and availability, instead of the product default. The address bar is normalized to the option-param form, so sharing or bookmarking produces the same URL the on-page picker would.
Unresolvable ids (a stale variant in an old feed, a malformed param) fall through and render the default variant rather than 404, so an outdated link degrades to today's behavior instead of an error page.
Out of scope
templates/nextjsandtemplates/react-router. Both templates depend on"@shopify/hydrogen": "preview"— the published npm package, not the workspace — so they cannot referencehandleVariantDeepLinkuntil a preview build ships it. Wiring them now would fail their typecheck. They are unchanged here and should follow in the usual "update Hydrogen preview package" pass. The wiring is two lines in eachproxy.ts/server.ts; happy to open that follow-up once this lands and publishes.node(id:)query runs per inbound deep link. Worth revisiting if it shows up in practice, but it seemed like the wrong thing to bake in without data.productInCollectionroutes. Only theproductroute template is matched. Shopify's feeds and ads emit/products/:handle, so collection-scoped product URLs with a variant param still render the default variant.Risk
searchParams.get, and the Storefront lookup only fires on a product-route URL carrying a well-formed variant id, so ordinary traffic short-circuits before any I/O.examples/hydrogenis typechecked but not runtime-verified — it has no mock.shop fallback and throws on a missingPRIVATE_STOREFRONT_API_TOKENbefore any of this code runs, so I could not exercise it locally. Its wiring mirrorsexamples/react-router, which is verified. Worth a reviewer with store credentials confirming.notes/product.mdandengineering.md. If maintainers would rather?variant=be served directly instead of redirected,getVariantIdParamstill applies and only the interceptor changes.How to Test
pnpm install && pnpm build:pkgs.pnpm dev:next(orpnpm dev:rrfor the React Router example)./products/slidesand note the default selection (Small)./products/slides?variant=43695710437398./products/slides?Size=Large, and that Large is the selected option with its price and availability./products/slides?variant=43695710404630&utm_source=google&ref=feed./products/slides?utm_source=google&ref=feed&Size=Medium— the campaign params survive./products/slides?variant=99999999999999(a stale id) and confirm the page renders the default variant rather than a 404./cart?variant=43695710437398and confirm no redirect happens.Steps 4–9 can also be checked with
curl -sIto see the307andlocationheader directly.Verified against
mock.shop, so no store credentials are needed for the Next.js and React Router examples.