Skip to content

Commit b86620f

Browse files
div-cowboyclaude
andcommitted
feat(hydrogen): resolve inbound ?variant=<id> deep links
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>
1 parent b262c53 commit b86620f

10 files changed

Lines changed: 474 additions & 5 deletions

File tree

.changeset/tidy-moons-repeat.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@shopify/hydrogen': minor
3+
---
4+
5+
Resolve inbound `?variant=<id>` deep links to the product URL's option-param form.
6+
7+
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 (`/products/shoes?variant=41565182099480`) rather than one search param per option. A storefront that only reads option params (`?Color=Red&Size=M`) silently drops that selection and renders the default variant, so every link a merchant already has in market lands on the wrong variant after migrating to Hydrogen.
8+
9+
Two new exports:
10+
11+
- `getVariantIdParam({ searchParams })` reads the param and normalizes it to a `ProductVariant` GID, returning `null` for anything that isn't one — including GIDs for other resource types, so an untrusted param can't be forwarded into a `node(id:)` lookup for an unrelated object.
12+
- `handleVariantDeepLink({ request, storefrontClient, routeTemplates, pathPrefix })` 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.
13+
14+
```ts
15+
const variantRedirect = await handleVariantDeepLink({ request, storefrontClient, routeTemplates });
16+
if (variantRedirect) return variantRedirect;
17+
```
18+
19+
Because it goes through `routeTemplates`, apps serving products from a custom path (`/p/:productHandle`) and apps using an i18n `pathPrefix` (`/en-ca/products/…`) work without extra configuration. The redirect preserves unrelated params (`utm_*`, `ref`) so campaign attribution survives, and follows the variant's own product handle so a combined-listing variant lands on the right page.
20+
21+
This is additive: option params remain the canonical URL contract and remain the no-JS mechanism. Unresolvable or stale ids return `null` and render the default variant rather than 404.
22+
23+
Run it before the route renders, not after a 404 like `handleShopifyRedirects` — the product route exists, and only the variant selection needs translating. Running it during rendering can degrade to a client-side redirect that never fires for a shopper with JavaScript disabled.

examples/hydrogen/server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createShopifyRequestContext,
66
handleShopifyRedirects,
77
handleShopifyRoutes,
8+
handleVariantDeepLink,
89
} from "@shopify/hydrogen";
910
import { createStorefrontClient } from "@shopify/hydrogen";
1011
import { createCustomerAccountServerHandlers } from "@shopify/hydrogen/customer-account";
@@ -75,6 +76,25 @@ export default {
7576
});
7677
if (shopifyRoute) return shopifyRoute;
7778

79+
/**
80+
* Shopify's own surfaces (Liquid storefronts, Shopping feeds, email, ads,
81+
* Shop Pay) deep-link with a bare `?variant=<id>`. Translate it to the
82+
* option-param URL the product route reads, before the app renders, so it
83+
* stays a real HTTP redirect for a shopper without JavaScript.
84+
*/
85+
const variantRedirect = await handleVariantDeepLink({
86+
request: publicRequest,
87+
routeTemplates,
88+
storefrontClient,
89+
});
90+
if (variantRedirect) {
91+
// Mirrors the `shopifyRoute` early return above: nothing has rendered
92+
// yet, so there are no pending session commits to flush — only the
93+
// SFAPI response headers need merging.
94+
shopifyRequestContext.applyResponseHeaders(variantRedirect.headers);
95+
return variantRedirect;
96+
}
97+
7898
const routerContext = await createHydrogenRouterContext(
7999
publicRequest,
80100
env,

examples/nextjs/proxy.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
createShopifyRequestContext,
55
createStorefrontClient,
66
handleShopifyRoutes,
7+
handleVariantDeepLink,
78
} from "@shopify/hydrogen";
89
import { NextResponse, type NextRequest } from "next/server";
910

1011
import { cartHandlers } from "@/lib/cart-handlers";
1112
import { createCustomerSessionManager } from "@/lib/customer-account";
1213
import { customerSessionHandlers } from "@/lib/customer-session-handlers";
1314
import { predictiveSearchHandlers } from "@/lib/predictive-search-handlers";
15+
import { routeTemplates } from "@/lib/route-templates";
1416
import { MOCK_SHOP_DOMAIN, resolveStorefrontConfig } from "@/lib/storefront-config";
1517

1618
/**
@@ -20,6 +22,13 @@ import { MOCK_SHOP_DOMAIN, resolveStorefrontConfig } from "@/lib/storefront-conf
2022
* `/api/{ver}/graphql.json`, `/admin`, …) short-circuit here. Storefront URL
2123
* redirects run in `app/not-found.tsx` (post-404), never here.
2224
*
25+
* `?variant=<id>` deep links (Liquid storefronts, Shopping feeds, email, ads,
26+
* Shop Pay) are normalized to the PDP's option-param URL here too — distinct
27+
* from the admin-configured Storefront URL redirects above, and placed before
28+
* framework routing so it stays a real HTTP redirect for no-JS shoppers (F4)
29+
* rather than the client-side one a page-level `redirect()` degrades to under
30+
* Cache Components.
31+
*
2332
* The original request URL is forwarded to Server Components via
2433
* `requestContext.getForwardedRequestHeaders()` (carries `x-storefront-url` for
2534
* `not-found.tsx` and `getMarketFromHeaders`). SFAPI response headers are merged
@@ -37,8 +46,6 @@ export async function proxy(request: NextRequest) {
3746
buyerIp,
3847
});
3948

40-
const sessionManager = await createCustomerSessionManager(request);
41-
4249
const { storeDomain, privateStorefrontToken } = resolveStorefrontConfig();
4350

4451
const storefrontClient = createStorefrontClient({
@@ -51,6 +58,26 @@ export async function proxy(request: NextRequest) {
5158
},
5259
});
5360

61+
// Normalize `?variant=<id>` deep links to the PDP's option-param URL. Runs
62+
// ahead of the session manager: a redirected request never reads the session,
63+
// so there's no reason to pay its cookie decrypt first.
64+
const variantRedirect = await handleVariantDeepLink({
65+
request,
66+
storefrontClient,
67+
routeTemplates,
68+
});
69+
if (variantRedirect) {
70+
// Hydrogen returns a relative `location`, which is what a framework router
71+
// wants. Next's proxy resolves the header with `new URL()` and throws on a
72+
// relative value, so rebuild it against the request origin here.
73+
const location = variantRedirect.headers.get("location") ?? "/";
74+
const response = NextResponse.redirect(new URL(location, request.url), variantRedirect.status);
75+
requestContext.applyResponseHeaders(response.headers);
76+
return response;
77+
}
78+
79+
const sessionManager = await createCustomerSessionManager(request);
80+
5481
// Customer Accounts are only available on a real store (mock.shop has no
5582
// Customer Account API). Inline the resolved `storeDomain` check rather than
5683
// calling `isCustomerAccountsAvailable()` to avoid a second config read.

examples/react-router/app/lib/storefront-middleware.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createStorefrontClient,
77
handleShopifyRedirects,
88
handleShopifyRoutes,
9+
handleVariantDeepLink,
910
type ShopifyRequestContext,
1011
} from "@shopify/hydrogen";
1112
import { createCustomerAccountClient } from "@shopify/hydrogen/customer-account";
@@ -145,6 +146,19 @@ export const storefrontMiddleware: MiddlewareFunction<Response> = async (
145146
// path needs no further post-processing.
146147
if (shopifyRoute) return shopifyRoute;
147148

149+
// Shopify's own surfaces (Liquid storefronts, Shopping feeds, email, ads,
150+
// Shop Pay) deep-link with a bare `?variant=<id>`. Translate it to the
151+
// option-param URL the product route reads. Runs before `next()` so it stays
152+
// a real HTTP redirect for a no-JS shopper (F4).
153+
const variantRedirect = await handleVariantDeepLink({
154+
request,
155+
storefrontClient,
156+
routeTemplates,
157+
});
158+
if (variantRedirect) {
159+
return finalizeResponse(requestContext, variantRedirect, sessionManager);
160+
}
161+
148162
// Loaders read both clients from context. Handlers don't read context, so
149163
// this only needs to be set on the framework-router path (after the
150164
// `shopifyRoute` early-return).

packages/hydrogen/src/core/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export type { RedirectOptions } from "./handle-shopify-redirects";
22
export { createShopifyRouteTemplates } from "./standard-routes/index";
33
export type { ShopifyRouteTemplates } from "./standard-routes/index";
44
export { handleShopifyRedirects } from "./handle-shopify-redirects";
5+
export { handleVariantDeepLink } from "./interceptors/variant-deep-link";
6+
export type { VariantDeepLinkOptions } from "./interceptors/variant-deep-link";
57
export { handleShopifyRoutes } from "./handle-shopify-routes";
68
export { createShopifyRouteHandler } from "./route-handlers";
79
export type {
@@ -178,7 +180,7 @@ export type {
178180
ValidProductSelectionResult,
179181
VariantSelectionResult,
180182
} from "./product";
181-
export { getSelectedProductOptions } from "./product";
183+
export { getSelectedProductOptions, getVariantIdParam } from "./product";
182184
export type {
183185
ProductInput,
184186
ProductAddToCartProps,
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { createStorefrontClient } from "../../client/client";
4+
import { createShopifyRequestContext } from "../headers";
5+
import { createShopifyRouteTemplates } from "../standard-routes/index";
6+
import { assert } from "../test-utils";
7+
import { handleVariantDeepLink } from "./variant-deep-link";
8+
9+
const DEFAULT_I18N = { country: "US", language: "EN" } as const;
10+
const LARGE_VARIANT_GID = "gid://shopify/ProductVariant/43695710437398";
11+
12+
function storefrontClient(request: Request) {
13+
return createStorefrontClient({
14+
type: "private",
15+
requestContext: createShopifyRequestContext({ request, i18n: DEFAULT_I18N }),
16+
config: {
17+
storeDomain: "test-store.myshopify.com",
18+
privateStorefrontToken: "test-private-token",
19+
buyerIp: "127.0.0.1",
20+
},
21+
});
22+
}
23+
24+
function options(
25+
request: Request,
26+
overrides: Partial<Parameters<typeof handleVariantDeepLink>[0]> = {},
27+
): Parameters<typeof handleVariantDeepLink>[0] {
28+
return {
29+
request,
30+
storefrontClient: storefrontClient(request),
31+
routeTemplates: createShopifyRouteTemplates({}),
32+
...overrides,
33+
};
34+
}
35+
36+
function variantResponse(handle: string, selectedOptions: Array<{ name: string; value: string }>) {
37+
return new Response(JSON.stringify({ data: { node: { product: { handle }, selectedOptions } } }));
38+
}
39+
40+
describe("handleVariantDeepLink", () => {
41+
let mockFetch: ReturnType<typeof vi.fn>;
42+
43+
beforeEach(() => {
44+
mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { node: null } })));
45+
vi.stubGlobal("fetch", mockFetch);
46+
});
47+
48+
it("redirects a bare variant id to the option-param URL", async () => {
49+
mockFetch.mockResolvedValueOnce(variantResponse("slides", [{ name: "Size", value: "Large" }]));
50+
51+
const request = new Request("https://my-app.com/products/slides?variant=43695710437398");
52+
const result = await handleVariantDeepLink(options(request));
53+
54+
assert(result, "expected variant deep link redirect");
55+
expect(result.status).toBe(307);
56+
expect(result.headers.get("location")).toBe("/products/slides?Size=Large");
57+
});
58+
59+
it("accepts a full ProductVariant GID", async () => {
60+
mockFetch.mockResolvedValueOnce(variantResponse("slides", [{ name: "Size", value: "Large" }]));
61+
62+
const url = `https://my-app.com/products/slides?variant=${encodeURIComponent(LARGE_VARIANT_GID)}`;
63+
const result = await handleVariantDeepLink(options(new Request(url)));
64+
65+
assert(result, "expected variant deep link redirect");
66+
expect(result.headers.get("location")).toBe("/products/slides?Size=Large");
67+
});
68+
69+
it("preserves unrelated campaign params", async () => {
70+
mockFetch.mockResolvedValueOnce(variantResponse("slides", [{ name: "Size", value: "Medium" }]));
71+
72+
const request = new Request(
73+
"https://my-app.com/products/slides?variant=43695710437398&utm_source=google&ref=feed",
74+
);
75+
const result = await handleVariantDeepLink(options(request));
76+
77+
assert(result, "expected variant deep link redirect");
78+
const location = result.headers.get("location") ?? "";
79+
expect(location.startsWith("/products/slides?")).toBe(true);
80+
expect(new URLSearchParams(location.split("?")[1])).toEqual(
81+
new URLSearchParams({ utm_source: "google", ref: "feed", Size: "Medium" }),
82+
);
83+
});
84+
85+
it("follows the variant's own product handle for combined listings", async () => {
86+
mockFetch.mockResolvedValueOnce(
87+
variantResponse("slides-wide", [{ name: "Width", value: "Wide" }]),
88+
);
89+
90+
const request = new Request("https://my-app.com/products/slides?variant=43695710437398");
91+
const result = await handleVariantDeepLink(options(request));
92+
93+
assert(result, "expected variant deep link redirect");
94+
expect(result.headers.get("location")).toBe("/products/slides-wide?Width=Wide");
95+
});
96+
97+
it("omits Shopify's Default Title sentinel for single-variant products", async () => {
98+
mockFetch.mockResolvedValueOnce(
99+
variantResponse("gift-card", [{ name: "Title", value: "Default Title" }]),
100+
);
101+
102+
const request = new Request("https://my-app.com/products/gift-card?variant=43695710437398");
103+
const result = await handleVariantDeepLink(options(request));
104+
105+
assert(result, "expected variant deep link redirect");
106+
expect(result.headers.get("location")).toBe("/products/gift-card");
107+
});
108+
109+
it("honors a custom product route template", async () => {
110+
mockFetch.mockResolvedValueOnce(variantResponse("slides", [{ name: "Size", value: "Large" }]));
111+
112+
const request = new Request("https://my-app.com/p/slides?variant=43695710437398");
113+
const result = await handleVariantDeepLink(
114+
options(request, {
115+
routeTemplates: createShopifyRouteTemplates({ product: "/p/:productHandle" }),
116+
}),
117+
);
118+
119+
assert(result, "expected variant deep link redirect");
120+
expect(result.headers.get("location")).toBe("/p/slides?Size=Large");
121+
});
122+
123+
it("preserves an i18n path prefix", async () => {
124+
mockFetch.mockResolvedValueOnce(variantResponse("slides", [{ name: "Size", value: "Large" }]));
125+
126+
const request = new Request("https://my-app.com/en-ca/products/slides?variant=43695710437398");
127+
const result = await handleVariantDeepLink(options(request, { pathPrefix: "/en-ca" }));
128+
129+
assert(result, "expected variant deep link redirect");
130+
expect(result.headers.get("location")).toBe("/en-ca/products/slides?Size=Large");
131+
});
132+
133+
it("ignores requests without a variant param", async () => {
134+
const request = new Request("https://my-app.com/products/slides?Size=Large");
135+
136+
expect(await handleVariantDeepLink(options(request))).toBeNull();
137+
expect(mockFetch).not.toHaveBeenCalled();
138+
});
139+
140+
it("ignores non-product routes carrying a variant param", async () => {
141+
const request = new Request("https://my-app.com/cart?variant=43695710437398");
142+
143+
expect(await handleVariantDeepLink(options(request))).toBeNull();
144+
expect(mockFetch).not.toHaveBeenCalled();
145+
});
146+
147+
it("ignores a GID for another resource type without querying", async () => {
148+
const url = `https://my-app.com/products/slides?variant=${encodeURIComponent("gid://shopify/Customer/1")}`;
149+
150+
expect(await handleVariantDeepLink(options(new Request(url)))).toBeNull();
151+
expect(mockFetch).not.toHaveBeenCalled();
152+
});
153+
154+
it("falls through when the variant does not resolve", async () => {
155+
const request = new Request("https://my-app.com/products/slides?variant=99999999999999");
156+
157+
expect(await handleVariantDeepLink(options(request))).toBeNull();
158+
});
159+
});

0 commit comments

Comments
 (0)