---
title: Rendering and deployment
description: Choose how Next.js resolves consent for your router, rendering mode
  and hosting, from streamed App Router layouts to static export, ISR, Cache
  Components and ensureStatic.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## 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](/docs/frameworks/next/optimization#bundle-the-manifest-during-builds)
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](/docs/frameworks/next/app-router#start-consent-resolution-in-the-root-layout)|
|App Router, the page must arrive with its banner|Root layout awaits `resolveConsent` inside `Suspense`|Yes. The page waits for consent.|[Await consent](/docs/frameworks/next/app-router#render-the-banner-in-the-server-html)|
|App Router with `cacheComponents: true`|Either layout above. Never await in the layout itself.|Yes, with either layout|[Cache Components](#use-cache-components)|
|Static, ISR or `'use cache'` pages|Browser, `ConsentRoot` without `state`|No|[Static, ISR and cached pages](#render-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`](#keep-pages-static-with-ensurestatic)|
|Pages Router with `getServerSideProps`|`withConsentProps` from `c15t/next/pages`|Yes|[Pages Router](/docs/frameworks/next/pages-router#resolve-consent-in-getserversideprops)|
|Pages Router pages without `getServerSideProps`|Browser, through the same `_app.tsx` and the consent API route|No|[Pages Router](/docs/frameworks/next/pages-router#mount-one-root-for-every-page)|
|`output: 'export'`|Browser, calling the backend directly|No|[Static export](/docs/frameworks/next/static-export)|
|A server app that should skip server work and local routes|Browser, calling backend `/init` with `mode: hosted()`|No|[Client-side initialization](/docs/frameworks/next/client-side)|
|Edge runtime|Not covered by an example or test|Not covered|[Edge runtime](#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](/docs/concepts/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`. 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 `Suspense` boundary. Awaiting request data in
  the root layout without one fails the build with a
  `blocking-prerender-dynamic` error. 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](/docs/frameworks/next/content-security-policy). If
`next dev` reports an unstable `Date.now()` at the `resolveConsent` call, see
[troubleshooting](/docs/frameworks/next/troubleshooting#why-does-nextjs-report-an-unstable-datenow-during-prerendering).

## 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:

```tsx title="app/layout.tsx"
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
} from 'c15t/next';
import type { ReactNode } from 'react';

import './globals.css';

// No resolveConsent: the browser resolves consent, so pages can be static.
const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			<ConsentRoot>
				{children}
				<ConsentBanner />
				<ConsentDialog />
				<footer>
					<ConsentDialogLink>Privacy settings</ConsentDialogLink>
				</footer>
			</ConsentRoot>
		</body>
	</html>
);

export default RootLayout;
```

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](/docs/frameworks/next/app-router#initialize-in-the-browser).
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](/docs/concepts/modes#manifest).

## Keep pages static with `ensureStatic`

Next.js 16.4 adds the
[`ensureStatic`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/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](#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](#render-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](/docs/frameworks/next/data-fetching-reference#offline-configuration).

The [fetching reference](/docs/frameworks/next/data-fetching-reference#mode-combinations)
lists what each mode does on the server and in the browser, and
[consent modes](/docs/concepts/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](/docs/frameworks/next/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](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin).

## 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/pages` takes the Node.js `req` and `res` from `getServerSideProps`
  and `pages/api` routes, so it needs the Node.js runtime.
* The App Router examples and tests run `resolveConsent` and the route handlers
  from `c15t/next/api` on 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](/docs/frameworks/next/components/consent-gate).

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](/docs/frameworks/next/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](/docs/frameworks/next/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

1. Run `next build` and read the route table. Routes under a layout that calls
   `resolveConsent` show as dynamic, or partial with Cache Components. Static
   and ISR pages under the browser layout stay static.
2. View the page source in a fresh session. The awaited layout and Pages Router
   `getServerSideProps` pages contain `data-testid="consent-banner-root"`. The
   streamed and browser layouts do not.
3. In DevTools Network, confirm no vendor request runs before you choose.
   With a manifest, server-rendered pages make no `/init` request.
4. Reject, reload, and confirm the banner stays closed and vendors stay blocked.
   Reopen Privacy settings and confirm the rejection is still selected.
5. Test two locations if your policies differ by region.

The [consent checks](/docs/guides/verify-consent) cover the rest.
