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: 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, 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_INDEXINGwaits itsRetry-After(30 seconds at most by default;indexingRetry: { maxWaitSeconds }changes the limit) and asks once more. PassindexingRetry: falseto fail fast inside a page render or route handler instead. -
Every catalog read's
fetchcarriesnext: { revalidate, tags }.revalidatedefaults to 60 seconds;next.tagson 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, withcatalogTags. -
Everything else is sent
cache: 'no-store'with nonext: 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.
| Tag | On |
|---|---|
throttle-catalog | Every 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 (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.
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: 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
503included) is thrown andonChangeis not called. -
revalidateTag('throttle-catalog')refreshes every catalog read; a narrower tag fromcatalogTagsrefreshes less.
A sitemap
The paging helpers walk every public handle, following cursors, and always end:
| Helper | Answers |
|---|---|
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
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 (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>
);
}
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 } }.