Static generation

Every segment is a dynamic route param, so prerendering a variant is ordinary generateStaticParams. What is prerendered is decided by the config, not by each page.

Every variant, from one layout

A layout below all three segments can generate params for all of them at once — Next lets a child generate params for the segments above it.

// app/[locale]/[prefs]/[flags]/layout.tsx
export const generateStaticParams = indicators.generateStaticParams

For two locales, a theme with two values and a tz with one, that is 2 × 3 × 2 = 12 combinations, and every page below the layout is prerendered for each of them.

One level at a time

With a layout at each level, each exports its own:

// app/[locale]/layout.tsx
export const generateStaticParams = indicators.locale.generateStaticParams
// app/[locale]/[prefs]/layout.tsx
export const generateStaticParams = indicators.prefs.generateStaticParams
// app/[locale]/[prefs]/[flags]/layout.tsx
export const generateStaticParams = indicators.flags.generateStaticParams

Next runs them top down and multiplies them, which comes to the same thing.

The long tail

Not every indicator has to multiply the build. A flag that lumps Accept-Language into a handful of names is worth prerendering; one with twenty values across three other indicators is not.

flags: {
  lang: { header: 'accept-language', values: { fr: 'fr.*', de: 'de.*' }, prerender: false },
}

A value with prerender: false is left out of generateStaticParams, and nothing else changes: dynamicParams is on by default, so a variant that was not prerendered renders on its first request and is cached from then on. Turn dynamicParams off only if every variant really is listed; a request for any other would then be a 404.

A route that switches on a flag

A flags segment is a literal string, and Next prefers a literal route segment to a dynamic one. So this is a page of its own:

app/[locale]/[prefs]/[flags]/login/page.tsx       the usual login page
app/[locale]/[prefs]/beta~on/login/page.tsx       the one for the beta cohort

With flags: { beta: { cookie: 'beta', values: ['on'] } }, a request carrying that cookie is rewritten to /en/-/beta~on/login and lands on the second file. It is prerendered like any other page, for every locale and preference combination above it, and the cohort is switched on and off by writing a cookie — no branch in the component, no runtime read.

The directory name is the canonical segment: sorted keys, . between pairs. Use indicators.flags.encode({ beta: 'on' }) to get it right. Three things follow from it being a literal:

// app/[locale]/[prefs]/beta~on/layout.tsx
export const generateStaticParams = () =>
  indicators.locale.generateStaticParams().flatMap(({ locale }) =>
    indicators.prefs.generateStaticParams().map(({ prefs }) => ({ locale, prefs }))
  )

export default async function BetaLayout({ children, params }: LayoutProps<'/[locale]/[prefs]/beta~on'>) {
  const locale = await indicators.locale.read(params)
  const prefs = await indicators.prefs.read(params)
  return (
    <html lang={locale} data-theme={prefs.theme}>
      <body>{children}</body>
    </html>
  )
}

An app that switches routes on flags often may prefer the root layout at [locale] or [prefs], above the fork, so nothing has to be repeated.

The same works one level up, for a preference: app/[locale]/theme~dark/… is a page tree that only the dark theme sees, and its layout then reads the locale alone.

What makes a page dynamic anyway

Prerendering happens only if the page does not opt out. cookies(), headers(), searchParams read outside a Suspense boundary, and useSearchParams in a client component without one all make the route dynamic — for every variant. Everything this library needs is in the params, so a page that stays with read and the layout wrappers stays static.