Product Catalog

Next.js SSR and caching

A Next.js store uses two keys: a server key for everything rendered on the server, and a browser key for the hooks in the browser. @usethrottle/catalog/server adds Next.js cache options to every catalog read, without importing Next.js. The examples target Next.js 15.

A server key on the server, a browser key in the client

Server Components, route handlers, getServerSideProps, static builds and sitemaps call the catalog without an Origin, from a few hosting addresses. Give them a server key: it has the higher per-key limit and no per-visitor limit, and the catalog refuses it on any request from a browser page (403 SERVER_KEY_IN_BROWSER). Keep it in a variable without the NEXT_PUBLIC_ prefix. See Keys and channels.

lib/catalog.ts
// lib/catalog.ts: server code only (Server Components, route handlers,
// getServerSideProps). A server key, from a variable without NEXT_PUBLIC_,
// so it never reaches the browser bundle.
import { createServerCatalogClient } from '@usethrottle/catalog/server';

export const catalog = createServerCatalogClient({
  publishableKey: process.env.CATALOG_SERVER_KEY ?? '',
  // Every catalog read is cached by Next.js for 5 minutes, under the
  // throttle-catalog tags. The default is 60 seconds.
  next: { revalidate: 300 },
});

In the browser, the CatalogProvider gets a browser key, which is public, with your site's origins as its allowed websites:

app/providers.tsx
// app/providers.tsx, rendered by app/layout.tsx around the page.
'use client';
import type { ReactNode } from 'react';
import { createCatalogClient } from '@usethrottle/catalog';
import { CatalogProvider } from '@usethrottle/catalog/react';

// In the browser: a browser key, with your site's origins as its allowed
// websites. It is public by design.
const catalog = createCatalogClient({
  publishableKey: process.env.NEXT_PUBLIC_CATALOG_BROWSER_KEY ?? '',
});

export function Providers({ children }: { children: ReactNode }) {
  return <CatalogProvider client={catalog}>{children}</CatalogProvider>;
}

For customer prices, give that browser client the customer token as shown on React. Render public prices on the server and let the hooks show the signed-in shopper's prices.

How the server client caches

createServerCatalogClient takes the options of createCatalogClient plus next:

  • During the catalog's first index build, a read that meets 503 STOREFRONT_INDEXING waits its Retry-After (30 seconds at most by default; indexingRetry: { maxWaitSeconds } changes the limit) and asks once more. Pass indexingRetry: false to fail fast inside a page render or route handler instead.
  • Every catalog read's fetch carries next: { revalidate, tags }. revalidate defaults to 60 seconds; next.tags on the client are added to each route's own tags.
  • A call can pass its own next, which replaces the derived one for that call: include the route's tags yourself, with catalogTags.
  • Everything else is sent cache: 'no-store' with no next: cart calls, Throttle's cart session, review eligibility, and any request that carries a customer token. A personal answer never enters Next.js's cache.
TagOn
throttle-catalogEvery catalog read.
throttle-catalog:<route>Every read of one route: config, products, filters, product, related, categories, category, tags, collections, collection, suggest, reviews.
throttle-catalog:<route>:<handle>A read for one handle: a product, its related products or reviews, a category or a collection.

catalogTags(route, handle?) builds them, for revalidateTag or a per-call next. Two catalog installations in one Next.js app share these tags, so revalidating one also refreshes the other.

App Router

A product page with generateStaticParams. staticProductParams(catalog, { limit }) answers up to limit (1,000 by default) public product handles, { handle } each, most recently updated first.

app/products/[handle]/page.tsx
// app/products/[handle]/page.tsx (Next.js 15, App Router)
import { notFound, permanentRedirect } from 'next/navigation';
import { isCatalogError } from '@usethrottle/catalog';
import { catalogTags, staticProductParams } from '@usethrottle/catalog/server';
import { catalog } from '@/lib/catalog';

// Builds up to 1,000 product pages ahead; the others render on first visit.
export function generateStaticParams() {
  return staticProductParams(catalog, { limit: 1000 });
}

async function load(handle: string) {
  try {
    // A per-call `next` replaces the client's, so keep the route's own tags.
    return await catalog.products.get(handle, {
      next: { revalidate: 600, tags: catalogTags('product', handle) },
    });
  } catch (error) {
    if (isCatalogError(error) && error.code === 'NOT_FOUND') notFound();
    throw error;
  }
}

export default async function ProductPage({ params }: { params: Promise<{ handle: string }> }) {
  const { handle } = await params;
  const answer = await load(handle);
  if ('redirectTo' in answer) permanentRedirect(`/products/${answer.redirectTo}`);

  const { product } = answer;
  return (
    <main>
      <h1>{product.title}</h1>
      {product.variants.map((variant) => (
        <p key={variant.sku}>
          {variant.title}: {variant.priceMinor} {variant.currency}
          {variant.lowStock ? ' (only a few left)' : ''}
        </p>
      ))}
    </main>
  );
}

Refresh when the catalog changes: syncCatalogGeneration

Every change to what a storefront can see moves the catalog's generation, a number config() answers. syncCatalogGeneration reads it without the cache and, when it differs from the one you saw last, awaits onChange, where you call revalidateTag. Call it from a cron job, every minute for example.

Keep the last generation somewhere durable
On a serverless host (Vercel and the like) a cron call often lands on a fresh instance, where a module variable starts empty: that call only records the generation, and a change made since the last call is never revalidated. Store previous in KV, a database row or an edge config instead. If a change is missed anyway, the pages' time-based revalidate still bounds how stale they get.
app/api/catalog-sync/route.ts
// app/api/catalog-sync/route.ts: call it every minute from a cron job.
import { revalidateTag } from 'next/cache';
import { syncCatalogGeneration } from '@usethrottle/catalog/server';
import { catalog } from '@/lib/catalog';

// Kept in memory here for brevity. On a serverless host each cold instance
// starts empty and its first call only records the generation, so keep it in
// KV or a database row instead; the pages' revalidate time still bounds how
// stale they get when a change is missed.
let previous: number | undefined;

export async function GET(request: Request) {
  if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response(null, { status: 401 });
  }
  // Reads /config uncached and compares generations, never clocks.
  const { generation, changed } = await syncCatalogGeneration({
    client: catalog,
    previous,
    onChange: () => revalidateTag('throttle-catalog'),
  });
  previous = generation;
  return Response.json({ generation, changed });
}
  • Generations are compared, never clocks. The first call only records the generation; an error (an indexing 503 included) is thrown and onChange is not called.
  • revalidateTag('throttle-catalog') refreshes every catalog read; a narrower tag from catalogTags refreshes less.

A sitemap

The paging helpers walk every public handle, following cursors, and always end:

HelperAnswers
listAllProductHandles(client, { pageSize?, max?, signal? })An async iterable of public product handles, 100 per request, at most 50,000 by default.
listAllCollectionHandles(client, options?)An async iterable of listed collection handles.
listAllCategoryHandles(client, { signal? })Every listed category handle (the tree, up to ten levels deep).
staticProductParams(client, { limit?, signal? }){ handle }[] for generateStaticParams.
app/sitemap.ts
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import {
  listAllCategoryHandles,
  listAllCollectionHandles,
  listAllProductHandles,
} from '@usethrottle/catalog/server';
import { catalog } from '@/lib/catalog';

const SITE = 'https://shop.example.com';
export const revalidate = 3600;

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const urls: MetadataRoute.Sitemap = [];
  // Public products only, 100 per request, at most 50,000 by default.
  for await (const handle of listAllProductHandles(catalog)) {
    urls.push({ url: `${SITE}/products/${encodeURIComponent(handle)}` });
  }
  for await (const handle of listAllCollectionHandles(catalog)) {
    urls.push({ url: `${SITE}/collections/${encodeURIComponent(handle)}` });
  }
  for (const handle of await listAllCategoryHandles(catalog)) {
    urls.push({ url: `${SITE}/categories/${encodeURIComponent(handle)}` });
  }
  return urls;
}

Pages Router

Fetch on the server with the server key, and hand the answer to the hook as initialData. The browser then refreshes it through the provider in pages/_app.tsx, with the browser key.

pages/categories/[handle].tsx
// pages/categories/[handle].tsx (Next.js 15, Pages Router). pages/_app.tsx
// wraps every page in a <CatalogProvider> with the browser key.
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import type { StorefrontListAnswer } from '@usethrottle/catalog';
import { useProducts } from '@usethrottle/catalog/react';
import { catalog } from '@/lib/catalog';

type Props = { handle: string; initial: StorefrontListAnswer };

// On the server, with the server key. Code used only here never reaches
// the browser bundle.
export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  const handle = String(params?.handle ?? '');
  const initial = await catalog.products.list({ category: handle, limit: 24 });
  return { props: { handle, initial } };
};

export default function CategoryPage({
  handle,
  initial,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  // In the browser, through the provider's browser key. The server's answer
  // shows at once; the hook fetches again once it is older than 60 s.
  const { data } = useProducts({ category: handle, limit: 24 }, { initialData: initial });
  return (
    <ul>
      {data?.products.map((product) => (
        <li key={product.handle}>{product.title}</li>
      ))}
    </ul>
  );
}
Caching in the Pages Router
Next.js keeps fetch answers in its data cache only in the App Router; in the Pages Router the next options change nothing. To cache pages there, use getStaticProps with revalidate, and staticProductParams for getStaticPaths, with each { handle } mapped to { params: { handle } }.