Complete Route Implementation Guide: Integrating Pack Content with Shopify Data
Version Compatibility: This documentation targets React Router 7 (@pack/hydrogen 3.x or 4.x and @pack/react 4.x). On 4.0, also see Upgrading to @pack/hydrogen 4.0. If your storefront is still on Remix, see the React Router 7 Migration guide.
This guide provides a detailed walkthrough for implementing a full route file in your Hydrogen storefront that integrates Pack content with Shopify data. We'll cover route setup, parameter handling, data fetching with appropriate caching, error handling, SEO metadata implementation, and how to combine multiple data sources (Pack, Shopify, metaobjects).
Prerequisites: Before implementing routes, ensure you've properly set up Pack with your Hydrogen project. See Hydrogen Integration: Adding Pack to Your Existing Project for setup instructions.
Understanding Routes in Pack
Pack storefronts are built on Hydrogen and React Router 7, the successor to Remix v2. Routes use React Router's file-based routing (via @react-router/fs-routes). In this system, the filenames and structure in your app/routes directory directly determine your application's URL routes.
File-Based Routing Basics
In React Router (and therefore Pack), routes are JavaScript or TypeScript files located in the app/routes directory. The filename maps to the URL pathname. For example:
app/routes/_index.tsx→/(homepage)app/routes/about.tsx→/aboutapp/routes/products.$handle.tsx→/products/:handle(dynamic route)
Route File Naming Conventions
-
Basic Routes
about.tsx→/about
-
Nested Routes with Dot Notation
concerts.trending.tsx→/concerts/trending
-
Dynamic Segments with
$products.$handle.tsx→/products/:handle
-
Index Routes with
_index_index.tsx→/(root)concerts._index.tsx→/concerts/
-
Layout Routes
concerts.tsxacts as a parent layout for nested routes
Route File Organization
You can also put a route in a folder. Name the folder the way you'd name the route file, and put a route.tsx file inside it. Only route.tsx becomes the route. Other files in the folder are ordinary modules, such as components or utilities, that the route can import. Folders don't nest routes: concerts/$city.tsx is not a route, so use a concerts.$city folder instead.
app/
├── routes/
│ ├── _index.tsx # /
│ ├── about.tsx # /about
│ ├── concerts/
│ │ ├── route.tsx # /concerts
│ │ └── utils.ts # not a route
│ ├── concerts.$city/
│ │ └── route.tsx # /concerts/:city
│ ├── concerts.trending.tsx # /concerts/trending
│ ├── products/
│ │ └── route.tsx # /products
│ └── products.$handle/
│ ├── route.tsx # /products/:handle
│ └── ProductDetails.tsx # not a route
└── root.tsx # Root layout
Here concerts/route.tsx is also the parent layout for /concerts/:city and /concerts/trending, so it must render an <Outlet /> for them to appear.
Route Setup and Basic Structure
Routes live in app/routes. A typical route file looks like this one, app/routes/($locale).pages.$handle.tsx. It loads a Pack page by its handle and renders the page's sections. Pack and Shopify are separate APIs, so fetch Shopify data with its own context.storefront.query() call (see Combining Data Sources).
import { useLoaderData } from 'react-router';
import { RenderSections } from '@pack/react';
import { PackTestRoute } from '@pack/hydrogen';
import type { Route } from './+types/($locale).pages.$handle';
// Pack query for the page's content and sections
const PAGE_QUERY = `#graphql
query Page($handle: String!) {
page: pageByHandle(handle: $handle) {
id
title
seo {
title
description
}
sections(first: 25) {
nodes {
id
data
}
}
}
}
`;
// Loader runs on the server
export async function loader({ params, context }: Route.LoaderArgs) {
const { handle } = params;
if (!handle) throw new Response(null, { status: 404 });
const { data, packTestInfo } = await context.pack.query(PAGE_QUERY, {
variables: { handle },
cache: context.storefront.CacheLong(),
});
if (!data?.page) throw new Response(null, { status: 404 });
// PackTestRoute reads packTestInfo from the route's loader data
return { page: data.page, packTestInfo };
}
// Optional SEO meta
export const meta: Route.MetaFunction = ({ loaderData }) => [
{ title: loaderData?.page.seo?.title || loaderData?.page.title },
{ name: 'description', content: loaderData?.page.seo?.description },
];
// Route component
export default function PageRoute() {
const { page } = useLoaderData<typeof loader>();
return (
<div>
<PackTestRoute />
<RenderSections content={page} />
</div>
);
}
This query returns up to 25 sections. For longer pages, request the next batch with sections(first: 25, after: $cursor) using pageInfo.endCursor, as the Blueprint's getPage() helper does.
Parameter Handling
Dynamic segments in filenames map to params:
// File: app/routes/($locale).products.$handle.tsx
export async function loader({ params }: Route.LoaderArgs) {
const { handle, locale } = params;
console.log(`Handle: ${handle}, Locale: ${locale || 'default'}`);
}
URL search params:
export async function loader({ request }: Route.LoaderArgs) {
const searchParams = new URL(request.url).searchParams;
const selectedOptions: Array<{ name: string; value: string }> = [];
searchParams.forEach((value, name) => {
selectedOptions.push({ name, value });
});
console.log(selectedOptions);
}
Data Fetching & Caching
Use context.pack and context.storefront:
export async function loader({ context, request }: Route.LoaderArgs) {
const { data: packData } = await context.pack.query(PACK_QUERY, {
variables: { handle },
cache: context.storefront.CacheLong(),
});
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: { handle, selectedOptions },
cache: context.storefront.CacheShort(),
});
const { realTimeData } = await context.storefront.query(REALTIME_QUERY, {
variables: { handle },
cache: context.storefront.CacheNone(),
});
return { packData, product, realTimeData };
}
CacheLong()– infrequent changesCacheShort()– frequent changesCacheNone()– no cache
Error Handling
context.pack.query() doesn't throw. If the request fails, it returns an error and data is null, so check error instead of wrapping the call in try/catch:
export async function loader({ params, context }: Route.LoaderArgs) {
const { data, error } = await context.pack.query(PAGE_QUERY, {
variables: { handle: params.handle },
cache: context.storefront.CacheLong(),
});
if (error) {
console.error(error);
throw new Response(null, { status: 500, statusText: 'Server Error' });
}
if (!data?.page) throw new Response(null, { status: 404 });
return { page: data.page };
}
If you do wrap other code in try/catch, rethrow Response objects in the catch with if (err instanceof Response) throw err;. Otherwise the catch turns a 404 into a 500.
To show your own message for these errors, export an ErrorBoundary from the route:
import { isRouteErrorResponse } from 'react-router';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error) && error.status === 404) {
return <div>Page not found</div>;
}
return <div>Something went wrong</div>;
}
SEO Metadata
Hydrogen's getSeoMeta() builds a route's meta tags, including JSON-LD schema markup, from an seo object that your loader returns. The Blueprint handles SEO this way (see app/lib/server-utils/seo.server.ts).
Meta Function
Return an seo object from your loader, then pass the seo of every matched route to getSeoMeta() in meta:
import { getSeoMeta } from '@shopify/hydrogen';
import type { SeoConfig } from '@shopify/hydrogen';
export async function loader({ params, context, request }: Route.LoaderArgs) {
const { handle } = params;
if (!handle) throw new Response(null, { status: 404 });
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: { handle },
});
if (!product) throw new Response(null, { status: 404 });
const seo: SeoConfig = {
title: product.title,
description: product.description.slice(0, 155),
url: request.url,
media: product.featuredImage?.url,
};
return { product, seo };
}
export const meta: Route.MetaFunction = ({ matches }) =>
getSeoMeta(
...matches.map((match) => (match?.loaderData as { seo?: SeoConfig })?.seo),
) || [];
getSeoMeta() turns title, description, url, and media into the title, description, Open Graph, and canonical tags. It merges the seo objects from all matched routes, so a site-wide seo from your root route applies to every page unless the page overrides it. Hydrogen logs a warning when a title is longer than 70 characters or a description is longer than 155, so shorten long values.
Schema Markup
Add JSON-LD to the same seo object with jsonLd:
const seo: SeoConfig = {
title: product.title,
description: product.description.slice(0, 155),
url: request.url,
media: product.featuredImage?.url,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage?.url,
},
};
getSeoMeta() renders each jsonLd entry as a <script type="application/ld+json"> tag in the page's <head>, together with JSON-LD from parent routes, such as an Organization entry from your root route.
If you don't use getSeoMeta(), you can return { 'script:ld+json': { ... } } from meta directly. A route's meta replaces its parent routes' tags rather than merging with them, so repeat any site-wide entries in each route.
Combining Data Sources
export async function loader({ params, context, request }: Route.LoaderArgs) {
const [packData, shopifyData, metaobjectData] = await Promise.all([
context.pack.query(PAGE_QUERY, { variables: { handle: params.handle }, cache: context.storefront.CacheLong() }),
context.storefront.query(PRODUCT_QUERY, { variables: { handle: params.handle }, cache: context.storefront.CacheShort() }),
context.storefront.query(METAOBJECT_QUERY, { variables: { handle: params.handle }, cache: context.storefront.CacheShort() }),
]);
if (!packData.data.productPage || !shopifyData.product) {
throw new Response(null, { status: 404 });
}
return {
productPage: packData.data.productPage,
product: shopifyData.product,
metaobject: metaobjectData.metaobject || null,
url: new URL(request.url).href,
};
}
A/B Testing Integration
In your component:
import { PackTestRoute } from '@pack/hydrogen';
<PackTestRoute />
In your loader:
const { data, packTestInfo } = await context.pack.query(PRODUCT_PAGE_QUERY, {
variables: { handle },
cache: context.storefront.CacheLong(),
});
return { productPage: data.productPage, packTestInfo };
TypeScript Typing
Type a section's content with an interface, and use the Section type from @pack/types when you register the section:
import { registerSection } from '@pack/react';
import type { Section } from '@pack/types';
interface HeroSectionData {
heading: string;
subheading?: string;
buttonText?: string;
buttonUrl?: string;
image?: { url: string; altText?: string };
}
export const HeroSection = ({ cms }: { cms: HeroSectionData }) => (
<div className="hero-section">
<h1>{cms.heading}</h1>
{cms.subheading && <p>{cms.subheading}</p>}
{cms.buttonText && <a href={cms.buttonUrl}>{cms.buttonText}</a>}
{cms.image && <img src={cms.image.url} alt={cms.image.altText} />}
</div>
);
HeroSection.Schema = {
label: 'Hero Section',
key: 'hero-section',
fields: [
{ component: 'text', name: 'heading', label: 'Heading', validate: { required: true } },
{ component: 'text', name: 'subheading', label: 'Subheading' },
{ component: 'text', name: 'buttonText', label: 'Button Text' },
{ component: 'text', name: 'buttonUrl', label: 'Button URL' },
{ component: 'image', name: 'image', label: 'Hero Image' },
],
};
// Register under the same name as the schema's key
registerSection(HeroSection as Section, { name: 'hero-section' });
- Name and key:
<RenderSections>finds each section's component by its schemakey, so register the section under the samename. If they differ, the section doesn't render, and no error is shown. - Schema checks:
registerSection()checks the schema against Pack's schema format and logs a warning for any problem. To catch mistakes when you type-check instead, addsatisfies SectionSchema(also from@pack/types) after the schema object. - Images: an image field's value is the selected media file. Read its address from
url, as the Blueprint's sections do. Some older content, including the Blueprint's default data, has nosrc.
Complete Product Route Example
This route loads a product's Pack content and its Shopify data in parallel, picks the variant from the URL's options, and adds SEO, JSON-LD, analytics, and A/B testing. If the Pack request fails, it logs the error and still renders the product.
app/routes/($locale).products.$handle.tsx
import { useLoaderData } from 'react-router';
import { Analytics, getSeoMeta } from '@shopify/hydrogen';
import type { SeoConfig } from '@shopify/hydrogen';
import { ProductProvider } from '@shopify/hydrogen-react';
import { RenderSections } from '@pack/react';
import { PackTestRoute } from '@pack/hydrogen';
import type { Route } from './+types/($locale).products.$handle';
// Pack: the product page's sections, edited in the Customizer
const PRODUCT_PAGE_QUERY = `#graphql
query ProductPage($handle: String!) {
productPage: productPageByHandle(handle: $handle) {
id
title
sections(first: 25) {
nodes {
id
data
}
}
}
}
`;
// Shopify: the product and the variant matching the URL's options
const PRODUCT_QUERY = `#graphql
query ProductDetails($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {
product(handle: $handle) {
id
title
description
vendor
featuredImage {
url
altText
}
selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions) {
id
title
price {
amount
currencyCode
}
}
}
}
`;
export async function loader({ params, context, request }: Route.LoaderArgs) {
const { handle } = params;
if (!handle) throw new Response(null, { status: 404 });
const selectedOptions: Array<{ name: string; value: string }> = [];
new URL(request.url).searchParams.forEach((value, name) => {
selectedOptions.push({ name, value });
});
const [packResult, { product }] = await Promise.all([
context.pack.query(PRODUCT_PAGE_QUERY, {
variables: { handle },
cache: context.storefront.CacheLong(),
}),
context.storefront.query(PRODUCT_QUERY, {
variables: { handle, selectedOptions },
cache: context.storefront.CacheShort(),
}),
]);
if (!product) throw new Response(null, { status: 404 });
// Pack content is optional here: log failures and render the product without it
if (packResult.error) console.error(packResult.error);
const selectedVariant = product.selectedOrFirstAvailableVariant;
const seo: SeoConfig = {
title: product.title,
description: product.description.slice(0, 155),
url: request.url,
media: product.featuredImage?.url,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage?.url,
},
};
return {
product,
productPage: packResult.data?.productPage ?? null,
selectedVariant,
seo,
// PackTestRoute reads the A/B test info from the route's loader data
packTestInfo: packResult.packTestInfo,
};
}
export const meta: Route.MetaFunction = ({ matches }) =>
getSeoMeta(
...matches.map((match) => (match?.loaderData as { seo?: SeoConfig })?.seo),
) || [];
export default function ProductRoute() {
const { product, productPage, selectedVariant } =
useLoaderData<typeof loader>();
return (
<ProductProvider data={product} initialVariantId={selectedVariant?.id}>
<PackTestRoute />
<h1>{product.title}</h1>
<p>
{selectedVariant?.price.amount} {selectedVariant?.price.currencyCode}
</p>
{productPage && <RenderSections content={productPage} />}
<Analytics.ProductView
data={{
products: [
{
id: product.id,
title: product.title,
price: selectedVariant?.price.amount || '0',
vendor: product.vendor,
variantId: selectedVariant?.id || '',
variantTitle: selectedVariant?.title || '',
quantity: 1,
},
],
}}
// The Blueprint's analytics integrations read these
customData={{ product, selectedVariant }}
/>
</ProductProvider>
);
}
Additional Resources
- React Router Routing Documentation
- React Router 7 Migration
- Hydrogen Integration: Adding Pack to Your Existing Project
- Templates in Pack
- Working with Sections
- A/B Testing Implementation
- Common Hydrogen Issues
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | May 2025 | Initial documentation release |
| 1.1 | May 2025 | Added Remix/React Router version compatibility |
| 1.2 | Sep 2026 | Updated examples for React Router 7 |
Note about Framework Updates: React Router and Hydrogen are actively developed. Check the React Router changelog and Hydrogen releases for breaking changes.