Crystallize logo

Next.js App Router on Crystallize: caching, streaming and pitfalls

Most Crystallize storefronts are built with Next.js. This page collects what several of them learned with the App Router and Cache Components: how to keep pages cached while personal parts stream in, how to get metadata into the HTML that crawlers read, and the traps that look like bugs in Crystallize but are not.

The rest of this section is framework-neutral. This page is specific to Next.js (App Router, version 16, React 19). The Next.js eCommerce Template is a complete example to start from.

Cache Components

Turn on cacheComponents and cache every published Discovery read in a 'use cache' function, with cacheLife and a cacheTag for revalidation. The client is on Caching and revalidation.

  • Keep request data out of the shared parts. Don't read cookies or headers in the layout. If the layout reads cookies, every route becomes dynamic.
  • Per-shopper parts stream behind `<Suspense>`: the "shopping as" badge, contract prices, a taste-ranked rail, the cart count. The rest of the page stays cached and shared.
  • Make the fallback real content where you can. While a personal ranking streams in, show the same category in its default (cached) order, not a skeleton.
  • Arguments to a cached function must be the same on every render. Next renders a prerender twice. An array whose order came from an API answer misses the cache on the second pass ("Unexpected cache miss after cache warming phase"), and the prefetch answers 503. Sort lists and keys.

Shop API calls are request data

Carts and orders belong to a request, never to a prerendered shell. Call connection() before a Shop API read, which also keeps clock reads (token expiry) out of the prerender:

import { connection } from 'next/server';

export async function shop<T>(query: string, variables: Record<string, unknown> = {}, endpoint: 'cart' | 'order' = 'cart') {
  await connection();
  return shopRequest<T>(query, variables, endpoint);
}

Server actions that change the cart call refresh() from next/cache when they finish, so the page shows the new cart without an extra round trip from the client.

Metadata in the HTML that crawlers read

Crawlers and AI agents that don't run JavaScript read <title>, the description, the canonical and hreflang from the raw HTML. Google ignores a canonical or hreflang outside <head>.

  • Next.js streams metadata into `<body>` for ordinary browsers. List the crawlers in htmlLimitedBots in next.config (search engines, AI crawlers, link-preview bots) so they get it in <head>.
  • `htmlLimitedBots` is not enough behind a CDN. A catch-all catalogue route ([...path]) without generateStaticParams serves one cached shell for every URL, and that shell can't hold metadata that depends on the path. On one store, Googlebot's real user agent got the cached shell with no title, description, canonical or hreflang at all.
  • The fix: list every sitemap path in generateStaticParams, so each URL has its own prerendered shell with its metadata in <head> for every user agent. Build metadata only from cached Discovery reads, and keep generateMetadata free of searchParams, cookies and headers.
// app/[lang]/[...path]/page.tsx
export async function generateStaticParams() {
  // The same list the sitemap uses: every published path in every language.
  const paths = await catalogPaths();
  return paths.map(({ lang, path }) => ({ lang, path: path.split('/').filter(Boolean) }));
}
  • Verify on production, per user agent. Fetch a product and a category page with curl and the real Googlebot desktop and smartphone user-agent strings, and check for <title> before </head> and the cache header. A local next start can behave differently from the CDN.

See Crawlers and sitemaps for the rest of the crawler setup.

Images

Don't use next/image for Crystallize images. The Crystallize CDN already serves every rendition, so a second optimizer only adds a hop and cost. If a project is committed to next/image, pass unoptimized. See Performance.

Routing details

  • Exclude icon routes from the language proxy (icon, apple-icon, favicon.ico), or they redirect to /en/….
  • Ship a real `/favicon.ico`. Without it the request falls into the [lang] route as a language.
  • A language switch fetches the target page first, then router.push, so the new language's shell doesn't flash blank.

Not-found pages

notFound() inside a streamed <Suspense> boundary runs after the status code is sent, so an unknown URL answers 200 with a "not found" page. Mark that page noindex so it never gets indexed. A real 404 needs the lookup outside Suspense, which blocks navigation until it finishes, and builds have reported mixed results with that under a static shell. Decide per route which trade-off you want, and test the status code on production.

Hosting

  • Run functions in the region next to the Crystallize API (for EU tenants, a European region such as fra1 on Vercel). Set it in the project and in vercel.json.
  • Watch cache writes. On a hosted cache every revalidation is a billed write. Revalidate hourly and on publish rather than every minute.
  • Retry Discovery on 429 and 5xx with backoff. A build or a burst of prefetches can hit the rate limit, and the answer is then an HTML error page, not JSON.