Skip to main content

Wire Hydrogen request handlers

A Shopify storefront answers more than page requests. Browser scripts call the Storefront API through the storefront's own origin, apps link to cart permalinks, customers follow checkout links, and old Liquid URLs need to redirect. Hydrogen's request handlers restore these routes in a headless storefront, so apps, analytics, and agents can integrate with your storefront the same way they integrate with a theme.

In this guide, you'll wire two functions around your framework's router: handleShopifyRoutes(), which answers Shopify-owned routes before your router runs, and handleShopifyRedirects(), which checks for Shopify redirects after your router returns a 404.


Hydrogen's request handlers are framework-neutral, but where they run depends on your framework. React Router follows this request flow:

Request
-> createShopifyRequestContext() once per request
-> handleShopifyRoutes() before framework routing
-> framework router
-> handleShopifyRedirects() only after a 404
-> framework 404 page
-> requestContext.applyResponseHeaders() on your final response
  1. Your server creates a Shopify request context with createShopifyRequestContext(). The Storefront API client and the request handlers share it for the rest of the request.
  2. handleShopifyRoutes() checks the request against the routes that Hydrogen owns. If the request matches, then it returns a response and your router never sees the request.
  3. If nothing matches, then your framework routes and renders the request as usual.
  4. If your router returns a 404, then handleShopifyRedirects() checks for a Shopify redirect for that URL. If one exists, then it returns the redirect.
  5. If there's no redirect, then your framework renders its 404 page.
  6. Before your response leaves the server, call requestContext.applyResponseHeaders(response.headers). Responses from handleShopifyRoutes() and handleShopifyRedirects() already include them.

handleShopifyRedirects() can make a Storefront API request, so it runs only after a 404.

Anchor to Routes Hydrogen handlesRoutes Hydrogen handles

handleShopifyRoutes() answers the following routes:

RoutePurpose
/api/{version}/graphql.jsonProxies Storefront API requests from browser scripts through your origin.
/__shopify/*Proxies other Shopify APIs that browser modules call.
/checkoutRedirects customers to checkout for the current cart, or to / without one.
/cart/{variantId}:{quantity}Cart permalinks that redirect to your store's domain (demostore.mock.shop for mock stores).
/buy/{itemId}:{quantity}Buy permalinks for agentic commerce.
/cart.js, /cart/add.js, /cart/update.js, /cart/change.js, /cart/clear.jsThe AJAX cart URLs that theme apps call.
/api/mcp, /api/ucp/mcpThe storefront's MCP endpoints for agents.
/account/login, /account/authorize, /account/logout, /account/refreshWithout a matching registered handler, GET and HEAD redirect to your store's domain, and other methods return 405.
GET or HEAD /.well-known/ucpProxies your store's UCP discovery profile.
/.well-known/*Allowlisted resources, like the Apple Pay domain association file, proxied from your Online Store.
/graphiqlA GraphiQL explorer. Development only.
/products/{handle}?variant={id}Redirects Liquid-style variant URLs to option parameters.
Registered handler groupsFor example, /api/cart, /api/predictive-search, and /account/login.

Checkout and cart permalink redirects outside / default to payment=shop_pay; an incoming payment parameter overrides it.

handleShopifyRedirects() handles the following requests after a 404:

  • /admin, which redirects to your store's Shopify admin.
  • Shopify's standard storefront paths, when route templates map them to your app's paths.
  • URL redirects that the merchant configures in the Shopify admin.
  • Same-origin redirects from query parameters.


Anchor to Step 1: Create a session managerStep 1: Create a session manager

handleShopifyRoutes() takes a session manager, which stores session values such as customer account tokens. Until you add customer accounts, you can use a session manager that keeps values in memory for the length of one request:

In-memory session manager

lib/session.ts
import type {WritableCustomerSessionManager} from '@shopify/hydrogen/customer-account';

export function createEphemeralSessionManager(request: Request): WritableCustomerSessionManager {
const data = new Map<string, unknown>();
const origin = new URL(request.url).origin;

return {
getSessionOrigin: () => origin,
getSessionItem: (key: string) => data.get(key),
setSessionItem: (key: string, value: unknown) => {
data.set(key, value);
},
removeSessionItem: (key: string) => {
data.delete(key);
},
};
}

When you add customer accounts, replace it with a cookie-backed session manager.


Anchor to Step 2: Run Shopify routes before your routerStep 2: Run Shopify routes before your router

Create a request handler that runs before your framework's router. It creates the Storefront API client and session manager, and passes the same objects to handleShopifyRoutes() that the rest of the request uses. It also checks for Shopify redirects after a 404 and applies response headers, which the next steps describe. The createRequestStorefrontClient() helper comes from Storefront API client.

Don't await handleShopifyRoutes() right away. It returns null synchronously when no route matches, and a promise of a Response when one does. Check the result first, and then return it.

Shopify request handler

lib/shopify.ts
import {
handleShopifyRedirects,
handleShopifyRoutes,
type StorefrontClient,
} from '@shopify/hydrogen';

import {routeTemplates} from './route-templates';
import {createEphemeralSessionManager} from './session';
import {createRequestStorefrontClient} from './storefront';

export async function handleRequest(
request: Request,
next: (request: Request, storefrontClient: StorefrontClient) => Promise<Response>,
): Promise<Response> {
const storefrontClient = createRequestStorefrontClient(request);
const {requestContext} = storefrontClient;
const sessionManager = createEphemeralSessionManager(request);

// Answer Shopify-owned routes before your router runs.
const shopifyResponse = handleShopifyRoutes({
request,
requestContext,
sessionManager,
storefrontClient,
routeTemplates,
handlers: [],
});
if (shopifyResponse) return shopifyResponse;

// Run your router. Pass the client so your loaders can reuse it.
let response = await next(request, storefrontClient);

// Check for a Shopify redirect only after your router returns a 404.
if (response.status === 404) {
const redirect = await handleShopifyRedirects({
request,
storefrontClient,
routeTemplates,
});
if (redirect) return redirect;
}

// Headers may be immutable, so copy the response before you change them.
response = new Response(response.body, response);
requestContext.applyResponseHeaders(response.headers);
return response;
}

Call handleRequest() from your app's request entry point, the first server code that runs for each request. Pass a next function that runs your framework's router and returns its response. In next, store the Storefront API client in request-scoped context, so your loaders reuse it instead of creating a second client. In Nuxt, run handleShopifyRoutes() in server middleware, handleShopifyRedirects() in error.vue after a 404, and applyResponseHeaders() in Nitro's beforeResponse hook. For the React Router and Next.js code, refer to Framework wiring.

If your server wraps requests in a try/catch that converts errors into a Response, then use return await shopifyResponse inside that block, so that a rejected route reaches your error handling.

In Next.js, the entry point is proxy.ts, which replaces middleware.ts in Next.js 16. The proxy can't pass the client to Server Components, so its next function forwards the request headers instead, including the original URL in the x-storefront-url header. Keep the proxy's config.matcher broad, so that it reaches /admin, /api/cart, and /api/{version}/graphql.json. Don't narrow it to a list of Hydrogen paths, because that list goes stale when Hydrogen adds routes.


Anchor to Step 3: Check for redirects after a 404Step 3: Check for redirects after a 404

handleRequest() calls handleShopifyRedirects() only after next() returns a 404. It takes the request, the Storefront API client, and your route templates. It doesn't take a session manager.

Add one more file, so that unknown paths reach this check:

Add a final splat route that returns a 404, so that unknown paths reach the root middleware as a 404 response:

Splat route

app/routes/catchall.tsx
export function loader() {
throw new Response('Not Found', {status: 404});
}

export default function CatchAllRoute() {
return null;
}

Register the splat route last in app/routes.ts:

Register the splat route

app/routes.ts
import {type RouteConfig, index, route} from '@react-router/dev/routes';

export default [
index('routes/home.tsx'),
route('*', 'routes/catchall.tsx'),
] satisfies RouteConfig;

Anchor to Step 4: Register handler groupsStep 4: Register handler groups

Hydrogen features that need their own server routes come as handler groups. Create each group once at module scope, and then pass it to handleShopifyRoutes() in the handlers array of handleRequest():

const shopifyResponse = handleShopifyRoutes({
request,
requestContext,
sessionManager,
storefrontClient,
routeTemplates,
handlers: [cartHandlers, predictiveSearchHandlers, customerAccountHandlers],
});

The following handler groups are available:

Handler groupCreated byRoutesGuide
CartcreateCartServerHandlers()/api/cartBuild cart UI
Predictive searchcreatePredictiveSearchServerHandlers()/api/predictive-searchAdd predictive search
Customer accountscreateCustomerAccountServerHandlers() from @shopify/hydrogen/customer-account/account/login, /account/authorize, /account/refresh, POST /account/logoutCustomer accounts

Because handleShopifyRoutes() answers these routes, your app doesn't need route files for them.

Link to the customer account routes with plain <a> and <form> elements, not your framework's client-side navigation components such as next/link or React Router's <Link> and <Form>.


Anchor to Step 5: Apply response headersStep 5: Apply response headers

Captured Shopify cookies reach non-HTML responses to non-document requests other than GET or HEAD when the request has an essential cookie, is a consent request, or explicitly establishes a session. Hydrogen computes cache-safety headers locally.

  • Responses that handleShopifyRoutes() and handleShopifyRedirects() return already include these headers. Return them as they are.
  • For every other response, call requestContext.applyResponseHeaders(response.headers) at the final response boundary, as the code in step 2 does.

If your app commits session cookies, then append the session headers to the response in handleRequest() first, and then call applyResponseHeaders():

response = new Response(response.body, response);
const sessionHeaders = new Headers((await sessionManager.commit?.()) ?? undefined);
for (const [name, value] of sessionHeaders) {
response.headers.append(name, value);
}

requestContext.applyResponseHeaders(response.headers);
return response;

Wire request handlers into your production server code, not a development-only hook. Hydrogen limits only /graphiql to development.


Anchor to Step 6: Verify your wiringStep 6: Verify your wiring

Start your development server, and then send the following requests. Replace <port> with your server's port and {version} with the Storefront API version that your client uses.

  1. Query the Storefront API proxy:

    Terminal

    curl -i -X POST http://localhost:<port>/api/{version}/graphql.json \
    -H "content-type: application/json" \
    -d '{"query":"{ shop { name } }"}'

    The response is Storefront API JSON, not your framework's 404 page. The proxy forwards the X-Shopify-Storefront-Access-Token header from the request when one is present. It doesn't add a token from your server config.

  2. Request /admin:

    Terminal

    curl -i http://localhost:<port>/admin

    The response redirects to your store's Shopify admin.

  3. Request a path that doesn't exist:

    Terminal

    curl -i http://localhost:<port>/this-does-not-exist

    The response is your framework's 404 page, unless the store has a URL redirect for that path.

  4. If you registered the cart handlers, then request /api/cart:

    Terminal

    curl -i http://localhost:<port>/api/cart

    Without a cart cookie, the response body is {"cart":null}.

  5. Request a product URL with a Liquid-style variant ID, such as /products/{handle}?variant={numeric_id}. Use your app's product path if your route templates change it. The response is a 302 redirect to the same product with option parameters. An invalid ID, such as ?variant=garbage, renders the product page as usual.

  6. If your route templates include productInCollection, then request a collection-scoped product URL:

    Terminal

    curl -i http://localhost:<port>/collections/{collection_handle}/products/{product_handle}

    The response redirects after a router 404 to the path in your template.

  7. If your route templates include a custom product path, then request /products/{handle}. The response redirects after a router 404 to your product path.

For the full list of checks, refer to the production checklist.


Hydrogen is framework-agnostic. This section shows where its pieces go in a React Router app and a Next.js App Router app, using code trimmed from the React Router and Next.js templates. For other frameworks, refer to Other frameworks.

The request entry point is a middleware export on the root route, which runs for every request. In React Router 7, turn on middleware with the future.v8_middleware flag. React Router 8 turns it on by default.

Turn on middleware

react-router.config.ts
import type {Config} from '@react-router/dev/config';

export default {
ssr: true,
future: {
v8_middleware: true,
},
} satisfies Config;

Call handleRequest() from the root middleware. In next, store the Storefront API client in router context, and then call the router's next():

Request entry point

app/lib/context.ts

import type {StorefrontClient} from '@shopify/hydrogen';
import {createContext} from 'react-router';

export const storefrontContext = createContext<StorefrontClient>();
import type {Route} from './+types/root';
import {storefrontContext} from './lib/context';
import {handleRequest} from './lib/shopify';

export const middleware: Route.MiddlewareFunction[] = [
({request, context}, next) =>
handleRequest(request, async (_request, storefrontClient) => {
context.set(storefrontContext, storefrontClient);
return next();
}),
];

Each route's loader reads the client from router context and runs on the server. Throw a 404 Response when the resource doesn't exist. Loaders from other guides, like loadProduct(), go in the same place.

Page data loader

app/routes/product.tsx
import {getSelectedProductOptions, gql} from '@shopify/hydrogen';

import {storefrontContext} from '~/lib/context';

import type {Route} from './+types/product';

const PRODUCT_QUERY = gql(`
query ProductPage($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {
product(handle: $handle) {
id
title
selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions) {
id
}
}
}
`);

export async function loader({context, params, request}: Route.LoaderArgs) {
const storefrontClient = context.get(storefrontContext);
const selectedOptions = getSelectedProductOptions({
searchParams: new URL(request.url).searchParams,
});
const {data} = await storefrontClient.graphql(PRODUCT_QUERY, {
variables: {handle: params.handle, selectedOptions},
});
if (!data?.product) throw new Response('Not Found', {status: 404});

return {product: data.product};
}

export default function ProductRoute({loaderData}: Route.ComponentProps) {
return <h1>{loaderData.product.title}</h1>;
}

In the same app/root.tsx, the root route's loader loads the cart and layout data with the request's client. It reads your environment variables from envContext, which the template's Oxygen Worker sets in server.ts. For that file, refer to Create the Worker entry point. On another runtime, set envContext in your server entry before the router runs. Layout renders ShopifyScripts in <head> with useNavigate() as navigate, and App hands the cart to the cart provider:

Root layout

app/root.tsx
import {ShopifyScripts} from '@shopify/hydrogen/react';
import type {ReactNode} from 'react';
import {Links, Meta, Outlet, Scripts, useNavigate, useRouteLoaderData} from 'react-router';

import {CartProvider} from '~/lib/cart';
import {cartHandlers} from '~/lib/cart-handlers';
import {storefrontContext} from '~/lib/context';
import {envContext} from '~/lib/env';
import {loadRootLayout} from '~/lib/root-layout';
import {routeTemplates} from '~/lib/route-templates';
import {resolveShopIdentity, storefrontConfig} from '~/lib/shop';

import type {Route} from './+types/root';

export async function loader({context, request}: Route.LoaderArgs) {
const env = context.get(envContext);
const storefrontClient = context.get(storefrontContext);
const [cartResult, layout] = await Promise.all([
cartHandlers.get({storefrontClient, request}),
loadRootLayout(storefrontClient),
]);

return {
cartData: cartResult.data,
shopIdentity: resolveShopIdentity(env, layout.shopId),
currency: layout.currency,
};
}

export function Layout({children}: {children: ReactNode}) {
const navigate = useNavigate();
// Absent when the root loader failed.
const rootData = useRouteLoaderData<typeof loader>('root');

return (
<html lang="en">
<head>
{rootData ? (
<ShopifyScripts
i18n={{...storefrontConfig.i18n, currency: rootData.currency}}
shop={rootData.shopIdentity.scriptShop}
navigate={navigate}
routes={routeTemplates}
/>
) : null}
<Meta />
<Links />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}

export default function App({loaderData}: Route.ComponentProps) {
return (
<CartProvider initialData={loaderData.cartData}>
<Outlet />
</CartProvider>
);
}

React examples in these docs are client components. React Router route modules render on the server and hydrate in the browser, so the examples work in them as they are. Leave out 'use client'.

The steps on this page work in any framework that gives your server code the incoming Request. The following table shows where each piece goes in frameworks that don't have a template. Each framework links to a development example in the Hydrogen repository that shows the full wiring. The examples show how the pieces fit together, but they aren't starter templates.

FrameworkRun Shopify routesStore the client for loadersCheck for redirects after a 404Apply response headers
NuxtServer middleware in server/middleware/event.contexterror.vue, when the status is 404A Nitro plugin that hooks beforeResponse
SvelteKitThe handle hook in src/hooks.server.tsevent.localsThe handle hook, after resolve() returnsThe handle hook
AstroonRequest in src/middleware.tslocalsonRequest, after next() returnsonRequest
SolidStartonRequest middleware in src/middleware.tsevent.localsA catch-all route, src/routes/[...404].tsxonBeforeResponse middleware

In Nuxt, server middleware receives an H3 event instead of a Request. Convert it with toWebRequest(event) before you pass it to Hydrogen, and return a matched Shopify route with sendWebResponse(event, response).

To load Shopify's scripts, call initializeShopifyScripts() from @shopify/hydrogen once in your root layout after it mounts. Pass your route templates, and your router's navigate function if it has one.



Was this page helpful?