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.
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.
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);
}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.
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>.
htmlLimitedBots in next.config (search engines, AI crawlers, link-preview bots) so they get it in <head>.[...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.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) }));
}// 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) }));
}<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.
<a> is a full page load.prefetch={true} once the shopper hovers, focuses or touches the link, and nothing before. Prefetching every card in view costs a server render per card.staleTimes.dynamic (for example 20 seconds). The default of 0 throws a prefetched page away the moment it arrives.next dev doesn't prefetch. A hidden or background tab prefetches nothing, and React reveals streamed Suspense content in requestAnimationFrame, which a hidden tab doesn't run, so the content looks stuck. That looks like a bug and isn't.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.
icon, apple-icon, favicon.ico), or they redirect to /en/….[lang] route as a language.router.push, so the new language's shell doesn't flash blank.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.
fra1 on Vercel). Set it in the project and in vercel.json.