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

  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 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 changes
  • CacheShort() – frequent changes
  • CacheNone() – 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


Version History

VersionDateChanges
1.0May 2025Initial documentation release
1.1May 2025Added 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.

Was this page helpful?