Skip to content

feat(hydrogen): resolve inbound ?variant=<id> deep links - #3898

Open
div-cowboy wants to merge 1 commit into
Shopify:previewfrom
div-cowboy:feat/variant-id-deep-links
Open

feat(hydrogen): resolve inbound ?variant=<id> deep links#3898
div-cowboy wants to merge 1 commit into
Shopify:previewfrom
div-cowboy:feat/variant-id-deep-links

Conversation

@div-cowboy

@div-cowboy div-cowboy commented Aug 1, 2026

Copy link
Copy Markdown

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:

/products/slides?variant=43695710437398   →   renders the default variant (Small)

The variant param is not an option name, so getSelectedProductOptions ignores it and the Storefront query falls back to selectedOrFirstAvailableVariant.

After

/products/slides?variant=43695710437398   →   307   →   /products/slides?Size=Large

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 the variant param and normalizes it to a ProductVariant GID, accepting both the bare legacy id Liquid emits and a full GID. Returns null for anything that is not a product variant id, including GIDs for other resource types, so an untrusted param cannot be forwarded into a node(id:) lookup for an unrelated object. Built on the existing parseGid, matching how shop-pay.ts already validates variant ids.
  • handleVariantDeepLink({ request, storefrontClient, routeTemplates, pathPrefix }) — resolves the variant and returns a 307 Response, or null when the request is not a product URL with a resolvable variant. Mirrors the handleUrlRedirects interceptor shape.
const variantRedirect = await handleVariantDeepLink({ request, storefrontClient, routeTemplates });
if (variantRedirect) return variantRedirect;

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 i18n pathPrefix (/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's Title / Default Title sentinel so single-variant products do not pick up a meaningless param.

Wired into examples/nextjs (in proxy.ts), examples/react-router (in the storefront middleware), and examples/hydrogen (in server.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

handleShopifyRedirects is 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 with new URL() and throws on a relative value. The Next.js example rebuilds it against the request origin — see examples/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/nextjs and templates/react-router. Both templates depend on "@shopify/hydrogen": "preview" — the published npm package, not the workspace — so they cannot reference handleVariantDeepLink until 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 each proxy.ts / server.ts; happy to open that follow-up once this lands and publishes.
  • Caching the variant lookup. The 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.
  • productInCollection routes. Only the product route 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

  • The redirect is 307, not 308, deliberately: option values are merchant-editable, so this mapping must not be cached in shoppers' browsers permanently.
  • It runs on every request in each app's pre-render hook. The variant-param check runs first and is a plain 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/hydrogen is typechecked but not runtime-verified — it has no mock.shop fallback and throws on a missing PRIVATE_STOREFRONT_API_TOKEN before any of this code runs, so I could not exercise it locally. Its wiring mirrors examples/react-router, which is verified. Worth a reviewer with store credentials confirming.
  • I inferred the URL-contract intent from notes/product.md and engineering.md. If maintainers would rather ?variant= be served directly instead of redirected, getVariantIdParam still applies and only the interceptor changes.

How to Test

  1. Run pnpm install && pnpm build:pkgs.
  2. Run pnpm dev:next (or pnpm dev:rr for the React Router example).
  3. Visit /products/slides and note the default selection (Small).
  4. Visit /products/slides?variant=43695710437398.
  5. Confirm the browser lands on /products/slides?Size=Large, and that Large is the selected option with its price and availability.
  6. Visit /products/slides?variant=43695710404630&utm_source=google&ref=feed.
  7. Confirm you land on /products/slides?utm_source=google&ref=feed&Size=Medium — the campaign params survive.
  8. Visit /products/slides?variant=99999999999999 (a stale id) and confirm the page renders the default variant rather than a 404.
  9. Visit /cart?variant=43695710437398 and confirm no redirect happens.
  10. Disable JavaScript in your browser and repeat step 4. Confirm you still land on the Large variant with a working add-to-cart form.

Steps 4–9 can also be checked with curl -sI to see the 307 and location header directly.

Verified against mock.shop, so no store credentials are needed for the Next.js and React Router examples.

@div-cowboy
div-cowboy requested a review from a team as a code owner August 1, 2026 16:17
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
div-cowboy force-pushed the feat/variant-id-deep-links branch from 673b422 to b86620f Compare August 1, 2026 17:10
@fredericoo

Copy link
Copy Markdown
Contributor

explored an alternative direction here #3952

can you give me your feedback?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants