Next.js
Rendering and deployment
Pick your rendering path
Find the row that matches your app. Most App Router apps with a Next.js server use the first row.
For server deployments, use the recommended build-time manifest with either router. A fresh server resolves from the deployment's snapshot; policy changes require a rebuild.
| Your app | Where consent resolves | Banner in the server HTML | Recipe |
|---|---|---|---|
| App Router with a Next.js server | Root layout starts resolveConsent and streams the result | Yes, in a later chunk, before hydration. The page renders without waiting. | Stream consent |
| App Router, the page must arrive with its banner | Root layout awaits resolveConsent inside Suspense | Yes. The page waits for consent. | Await consent |
App Router with cacheComponents: true | Either layout above. Never await in the layout itself. | Yes, with either layout | Cache Components |
Static, ISR or 'use cache' pages | Browser, ConsentRoot without state | No | Static, ISR and cached pages |
Routes that set ensureStatic | 'shell' or 'prefetch': either App Router layout. 'navigation': browser, without state | Same as the layout you use | Keep pages static with ensureStatic |
Pages Router with getServerSideProps | withConsentProps from c15t/next/pages | Yes | Pages Router |
Pages Router pages without getServerSideProps | Browser, through the same _app.tsx and the consent API route | No | Pages Router |
output: 'export' | Browser, calling the backend directly | No | Static export |
| A server app that should skip server work and local routes | Browser, calling backend /init with mode: hosted() | No | Client-side initialization |
| Edge runtime | Not covered by an example or test | Not covered | Edge runtime |
Server rendering puts the visitor's answer in the first response. The browser then applies it without another request, so gated scripts can load as soon as the page hydrates. Browser rendering sends the same HTML to every visitor and resolves consent after the page loads. In both cases optional categories stay denied, and the banner stays hidden, until a policy has resolved. Choose your setup explains this across frameworks.
Stream or await consent in the App Router
Both App Router layouts call resolveConsent from c15t/next/server in the
root layout. They differ in what waits for it.
| Layout | What the visitor gets | Cost |
|---|---|---|
| Streamed, the default | The page renders at once. The banner follows in a later chunk of the same response and shows before hydration. The dialog, gated scripts and embeds wait for the browser to apply the result. | The banner is a separate streamed reveal, which React can hold for up to 300 ms after an earlier one. |
Awaited inside Suspense | The banner is in the server HTML and does not change after hydration. | The page content streams after consent resolves, in a later chunk of the same response. |
The streamed layout passes the pending promise to ConsentRoot, so a slow or
failed manifest request never holds the page. Start with it.
The awaited layout moves the page inside the consent boundary. With runtime
fetching, a slow manifest response keeps the page blank for up to timeoutMs,
500 ms by default. React
can also hold a streamed boundary's reveal for up to 300 ms after the browser's
first paint or an earlier boundary's reveal, even with a warm manifest cache.
<SpeedInsights /> from @vercel/speed-insights is one such boundary, because
it wraps itself in Suspense. It also reports before consent, so load Speed
Insights through
vercelSpeedInsights instead.
Choose the awaited layout only when the page and its banner must arrive
together, and measure the page.
Without cacheComponents, you can also make the root layout itself async and
await resolveConsent there. The page and the banner then arrive together, but
the response starts only after consent resolves.
Use Cache Components
With cacheComponents: true, Next.js prerenders a static shell and fills
request-time parts per request. Both App Router layouts work:
- The streamed layout stays synchronous. The page content stays in the static shell and consent resolves per request.
- The awaited layout needs its
Suspenseboundary. Awaiting request data in the root layout without one fails the build with ablocking-prerender-dynamicerror. The page content moves into the per-request part.
Next.js suggests export const instant = false to fix that error. Don't use it
for consent. It only turns the check off. The layout still waits for consent
before it sends anything, so every route under it renders per request with no
static shell. Add the Suspense boundary instead.
With partialPrefetching: true, prefetches never resolve consent.
resolveConsent calls connection() before it reads the request, and
connection() never finishes during a prefetch. A <Link prefetch={true}>
that renders the layout on the server stops there. Consent resolves once, for
the page load. Client navigation keeps the same root layout, so it does not
resolve again.
ConsentGate does not read the clock while rendering, so a page that renders
it can stay in the static shell without its own Suspense boundary.
Cache Components rejects export const revalidate. Cache page content with
'use cache' and cacheLife instead, and resolve consent for those pages in
the browser as described in the next section.
Two Cache Components issues have their own pages. A per-request CSP nonce does
not work with a prerendered shell; see
Content Security Policy. If
next dev reports an unstable Date.now() at the resolveConsent call, see
troubleshooting.
Render static, ISR and cached pages
resolveConsent reads the request's cookies and headers. A layout that calls
it makes every route under it dynamic. A static, ISR or 'use cache' page is
shared between visitors, so it cannot contain one visitor's consent. Resolve
consent for those pages in the browser:
Without state, ConsentRoot resolves the visitor's consent after
hydration. The page can be static, use export const revalidate for ISR, or
use 'use cache'. Set routePrefix: '/api/c15t' in c15t.config.ts and
mount the consent route from the
App Router guide.
The browser then asks /api/c15t/init, which resolves the bundled policy on
your server with the browser request's location headers. Without
routePrefix, the browser asks ${backendURL}/init.
For a whole app of static pages, use this as the only root layout. To mix
static pages with server-rendered ones, give each kind its own root layout in
a route group, such as app/(site)/layout.tsx with the streamed layout and
app/(static)/layout.tsx with this one. Moving between two root layouts loads
the whole page. The visitor's choice survives because c15t stores it in a cookie and
localStorage.
To resolve the policy in the browser itself, set
mode: manifest({ resolve: 'browser' }). The browser then loads the resolver
and fetches ${routePrefix}/manifest. It has no location input of its own,
so a location-based policy still asks /init unless you pass geoURL. See
consent modes.
Keep pages static with ensureStatic
Next.js 16.4 adds the
ensureStatic
route segment config. It tells Next.js which parts of a route must stay
static, so the server does no per-request work for them. Which level you can
use depends on your consent layout:
ensureStatic | Streamed layout | Awaited layout | Browser layout |
|---|---|---|---|
'shell' | Works | Works, but links show only the Suspense fallback before the click | Works |
'prefetch' | Works | Works, but links show only the Suspense fallback before the click | Works |
'navigation' | Build fails | Build fails | Works |
Every level needs cacheComponents: true. 'shell' and 'prefetch' also
need partialPrefetching: true. At both levels, prefetches never resolve
consent, including a <Link prefetch={true}> under 'shell', as
Use Cache Components explains. One visitor's consent
never ends up in a prefetch or in static output. Use the streamed layout if
prefetched pages should show their content before the click.
'navigation' requires the whole route to be static, including the root layout
above the page. A layout that calls resolveConsent reads the request, so the
build fails. Give those pages the browser layout in their own route group, as
Static, ISR and cached pages shows.
Don't wrap resolveConsent in 'use cache' to get past the error. It reads
the visitor's cookies, and a cached result would show one visitor's consent to
everyone.
Choose how the server gets the policy
The rendering path decides when consent resolves. The mode in
c15t.config.ts decides where the policy comes from.
| Source | Config | Use it when |
|---|---|---|
| Build-time manifest, recommended | manifest(), the default, with withConsentManifest | Your app has a Next.js server and policy changes ship with a rebuild. |
| Cached manifest | manifest({ source: 'runtime' }) | Policy changes must apply without a rebuild. |
Backend /init | hosted() | You want the fewest moving parts, or you export a static site. |
| Offline | offline() | Local development and tests. Not recommended for production environments. |
With a build-time manifest, your Next.js server resolves each visitor locally
without fetching policy. With runtime caching, warm requests make no policy
request to the backend, but an empty cache fetches upstream.
With backend /init, every server render or browser initialization
asks the backend to resolve the visitor. Consent saves go to
${backendURL}/subjects either way.
Offline mode keeps policy in your code and choices in the browser, with no
consent records. It runs only with mode: offline(); without a backend URL,
manifest() and hosted() throw. See
offline configuration.
The fetching reference lists what each mode does on the server and in the browser, and consent modes compares them across frameworks.
Get the visitor's location
Location-based policies need trusted location headers from your host, such as
x-vercel-ip-country or cf-ipcountry. Without them, c15t applies your
unknown-location rule. Some hosts pass those headers to proxy.ts but strip
them before Server Components run. Add c15tProxy from c15t/next/proxy in
that case, as Forward geography headers
shows. A static export has no server and cannot read location headers.
Keep browser requests on your origin
proxy: true in c15t.config.ts, with a consent route that forwards saves,
sends the browser's saves through /api/c15t instead of the backend's domain.
It is optional, saves the browser a separate connection, and changes nothing
about when consent resolves. It needs a Next.js server. See
Optimization.
Use a src directory
With src/, keep c15t.config.ts at the project root, next to
next.config.ts: withConsentManifest looks for it there and nowhere else.
Put proxy.ts under src/ next to src/app or src/pages, as Next.js
requires.
Edge runtime
No c15t example or compatibility test runs on the Edge runtime. What is known:
c15t/next/pagestakes the Node.jsreqandresfromgetServerSidePropsandpages/apiroutes, so it needs the Node.js runtime.- The App Router examples and tests run
resolveConsentand the route handlers fromc15t/next/apion the default Node.js runtime.
Keep the default runtime for layouts, pages and route handlers that use c15t.
If you set export const runtime = 'edge' on them, test the result yourself.
Keep server and browser state aligned
Pass the resolveConsent result to ConsentRoot unchanged. Both read the
same c15t.config.ts, so they agree on the mode and the journey. The result
carries the
resolved policy, the stored records and the evaluation time. Replacing it with a
browser preset, or turning allowed categories into saved choices during
hydration, can change the first render's permissions or prompt. Keep one
ConsentRoot mounted across client navigation.
With server state, ConsentGate renders the placeholder for a denied category
in the server HTML. A granted category gets an empty wrapper, and the embed
mounts after hydration, so an iframe loads once even when the page streams
inside Suspense. See ConsentGate.
If the server request fails or runs past timeoutMs, resolveConsent returns
a baseline state without a policy. The page still renders, optional categories
stay denied, and the browser retries after hydration. See
troubleshooting.
With mode: offline(), or with no backend URL at all, resolveConsent()
makes no backend request. It returns the visitor's stored records, location,
language and privacy signals, with no resolved policy.
Taps before hydration
A banner in the server HTML shows before the page's JavaScript runs. On a slow
phone that gap can last seconds. The stock ConsentBanner puts a small inline
script in front of its buttons that holds an Accept all, Reject all, notice
dismiss or Customize tap made in that gap. Accept, reject and dismiss hide the
banner at once. When the banner hydrates and the runtime has started, c15t
records the choice with the time of the tap, saves it and loads the scripts it
allows; Customize opens the dialog.
A tap made on a banner for one model or prompt is dropped when the browser
resolves another, and the banner shows again. A banner you build from hooks
has no inline script, so a tap before hydration does nothing there, and a
page with two stock banners holds no early taps at all. Neither
does a button in a composed ConsentBanner that has its own onClick,
asChild, type="submit" or performDefaultAction={false}, so your handler,
link or form still decides that tap. A tap on a banner with its own uiSource
is recorded with that source. Under a
Content Security Policy the script needs options.nonce; see
Content Security Policy.
Cache policy, never visitor state
A manifest is public policy configuration, so your server and CDN can cache it.
A resolved state, a stored record and HTML rendered with them belong to one
visitor. Keep them out of shared caches. Do not cache a route whose layout
awaits resolveConsent, and do not store resolveConsent results under a
shared key. The prerendered static shell under Cache Components holds no
visitor state, so Next.js can cache it.
Verify your rendering path
- Run
next buildand read the route table. Routes under a layout that callsresolveConsentshow as dynamic, or partial with Cache Components. Static and ISR pages under the browser layout stay static. - View the page source in a fresh session. The awaited layout and Pages Router
getServerSidePropspages containdata-testid="consent-banner-root". The streamed and browser layouts do not. - In DevTools Network, confirm no vendor request runs before you choose.
With a manifest, server-rendered pages make no
/initrequest. - Reject, reload, and confirm the banner stays closed and vendors stay blocked. Reopen Privacy settings and confirm the rejection is still selected.
- Test two locations if your policies differ by region.
The consent checks cover the rest.