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 → /about
  • app/routes/products.$handle.tsx → /products/:handle (dynamic route)

Route File Naming Conventions

  1. Basic Routes

    • about.tsx → /about
  2. Nested Routes with Dot Notation

    • concerts.trending.tsx → /concerts/trending
  3. Dynamic Segments with $

    • products.$handle.tsx → /products/:handle
  4. Index Routes with _index

    • _index.tsx → / (root)
    • concerts._index.tsx → /concerts/
  5. Layout Routes

    • concerts.tsx acts 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 changes
  • CacheShort() – frequent changes
  • CacheNone() – 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 schema key, so register the section under the same name. 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, add satisfies 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 no src.

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


Version History

VersionDateChanges
1.0May 2025Initial documentation release
1.1May 2025Added Remix/React Router version compatibility
1.2Sep 2026Updated 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.

Was this page helpful?