Complete Route Implementation Guide: Integrating Pack Content with Shopify Data
Version Compatibility: This documentation is compatible with:
- Remix v2.0.0 and later
- React Router v7.0.0 and later
Note: As of May 2025, Remix and React Router have merged, with React Router v7 now being the recommended upgrade path for Remix applications. This guide follows conventions that work across both Remix v2 and React Router v7.
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 Remix Routes in Pack
Framework Evolution: The routing system described here follows Remix's file-based routing conventions. In 2024, Remix and React Router merged, with React Router v7 becoming the successor to Remix v2. Pack Digital continues to follow these routing conventions, and this guide applies to both Remix v2 and React Router v7-based applications.
Pack is built on Remix, which uses a file-based routing system. In this system, the filenames and structure in your app/routes
directory directly determine your application's URL routes.
File-Based Routing Basics
In Remix (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
→/about
app/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.tsx
acts as a parent layout for nested routes
Route File Organization
You can organize routes in folders. Each folder must include a route.tsx
file:
app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts/
│ │ ├── route.tsx # /concerts
│ │ ├── $city.tsx # /concerts/:city
│ │ ├── trending.tsx # /concerts/trending
│ │ └── utils.ts # non-route module
│ └── products/
│ ├── route.tsx # /products
│ └── $handle.tsx # /products/:handle
└── root.tsx # Root layout
Route Setup and Basic Structure
Routes live in app/routes
. A typical route file looks like this:
import { json } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@shopify/remix-oxygen';
import { RenderSections } from '@pack/react';
import { PackTestRoute } from '@pack/hydrogen';
// GraphQL query for Pack and Shopify data
const ROUTE_QUERY = `#graphql
query RouteData($handle: String!, $selectedOptions: [SelectedOptionInput!]) {
packData {
# your Pack fields
}
shopifyData {
# your Shopify fields
}
}
`;
// Loader runs server-side
export async function loader({ params, context, request }: LoaderFunctionArgs) {
// fetch and return data
return json({
// data
});
}
// Optional SEO meta
export const meta = ({ data }) => [
{ title: data.title },
{ name: 'description', content: data.description },
];
// Route component
export default function Route() {
const data = useLoaderData<typeof loader>();
return (
<div>
<PackTestRoute />
<RenderSections content={data.pageContent} />
</div>
);
}
Parameter Handling
Dynamic segments in filenames map to params
:
// File: app/routes/($locale).products.$handle.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const { handle, locale } = params;
console.log(`Handle: ${handle}, Locale: ${locale || 'default'}`);
}
URL search params:
export async function loader({ request }: LoaderFunctionArgs) {
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 }: LoaderFunctionArgs) {
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 json({ packData, product, realTimeData });
}
CacheLong()
– infrequent changesCacheShort()
– frequent changesCacheNone()
– no cache
Error Handling
export async function loader({ params, context }: LoaderFunctionArgs) {
try {
const { data } = await context.pack.query(PAGE_QUERY, {
variables: { handle: params.handle },
cache: context.storefront.CacheLong(),
});
if (!data.page) throw new Response(null, { status: 404 });
return json({ page: data.page });
} catch (err) {
console.error(err);
throw new Response(null, { status: 500, statusText: 'Server Error' });
}
}
In your component:
export default function ProductRoute() {
const { product } = useLoaderData<typeof loader>();
if (!product) return <div>Product could not be loaded</div>;
return <Product product={product} />;
}
SEO Metadata
Meta Function
export const meta: MetaFunction = ({ data }) => [
{ title: `${data.product.title} | ${data.siteTitle}` },
{ name: 'description', content: data.product.description.substring(0, 160) },
{ property: 'og:title', content: data.product.title },
{ property: 'og:description', content: data.product.description.substring(0, 160) },
{ property: 'og:image', content: data.product.featuredImage?.url },
{ name: 'twitter:card', content: 'summary_large_image' },
];
Schema Markup
import { ProductSchemaMarkup } from '~/components/SchemaMarkup';
<ProductSchemaMarkup
product={product}
siteTitle={siteTitle}
url={url}
/>
Combining Data Sources
export async function loader({ params, context }: LoaderFunctionArgs) {
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 json({
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 json({ productPage: data.productPage, packTestInfo });
TypeScript Typing
import type { Section } from '@pack/types';
interface HeroSectionData {
heading: string;
subheading?: string;
buttonText?: string;
buttonUrl?: string;
image?: { src: 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.src} alt={cms.image.altText} />}
</div>
);
HeroSection.Schema = {
label: 'Hero Section',
key: 'heroSection',
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' },
],
};
Complete Product Route Example
import { json } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs, MetaFunction } from '@shopify/remix-oxygen';
import { RenderSections } from '@pack/react';
import { PackTestRoute } from '@pack/hydrogen';
import { ProductProvider } from '~/components/ProductProvider';
import { Product } from '~/components/Product';
import { ProductSchemaMarkup } from '~/components/SchemaMarkup';
const PRODUCT_PAGE_QUERY = `#graphql
query ProductPage($handle: String!) {
productPage(handle: $handle) { ... }
}
`;
const PRODUCT_QUERY = `#graphql
query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]) {
product(handle: $handle) { ... }
}
`;
const SITE_SETTINGS_QUERY = `#graphql
query SiteSettings {
siteSettings { ... }
}
`;
export const meta: MetaFunction = ({ data }) => {
if (!data) return [{ title: 'Product Not Found' }];
const seo = data.productPage?.seo;
return [
{ title: seo?.title || `${data.product.title} | ${data.siteTitle}` },
{ name: 'description', content: seo?.description || data.product.description.substring(0, 160) },
{ property: 'og:title', content: data.product.title },
{ property: 'og:description', content: data.product.description.substring(0, 160) },
{ property: 'og:image', content: data.product.images.nodes[0]?.url },
{ name: 'twitter:card', content: 'summary_large_image' },
];
};
export async function loader({ params, context, request }: LoaderFunctionArgs) {
const { handle } = params;
if (!handle) throw new Response('Product handle is required', { status: 400 });
const selectedOptions: Array<{ name: string; value: string }> = [];
new URL(request.url).searchParams.forEach((value, name) => {
selectedOptions.push({ name, value });
});
try {
const [packData, shopifyData, siteSettingsData] = 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() }),
context.pack.query(SITE_SETTINGS_QUERY, { cache: context.storefront.CacheLong() }),
]);
const product = shopifyData.product;
if (!product) throw new Response('Product not found', { status: 404 });
const selectedVariant = product.selectedVariant ?? product.variants.nodes[0];
if (selectedVariant) {
context.storefront.sendShopifyAnalytics({
pageType: 'product',
resourceId: product.id,
products: [{
productGid: product.id,
variantGid: selectedVariant.id,
name: product.title,
variantName: selectedVariant.title,
brand: product.vendor,
price: selectedVariant.price.amount,
}],
});
}
return json({
product,
productPage: packData.data.productPage,
selectedVariant,
siteTitle: siteSettingsData.data.siteSettings.seo.title,
url: new URL(request.url).href,
packTestInfo: packData.packTestInfo,
});
} catch (error) {
console.error('Error fetching product:', error);
throw new Response('Error fetching product data', { status: 500 });
}
}
export default function ProductRoute() {
const { product, productPage, selectedVariant, siteTitle, url } = useLoaderData<typeof loader>();
return (
<ProductProvider data={product} initialVariantId={selectedVariant?.id || null}>
<PackTestRoute />
<Product product={product} />
{productPage && <RenderSections content={productPage} />}
<ProductSchemaMarkup product={product} siteTitle={siteTitle} url={url} />
</ProductProvider>
);
}
Additional Resources
- Remix Routes Documentation
- React Router v7 Documentation
- 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 |
Note about Framework Updates: Remix and React Router are actively developed. Check the official Remix blog and React Router docs for breaking changes.