Skip to main content

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 appWhere consent resolvesBanner in the server HTMLRecipe
App Router with a Next.js serverRoot layout starts resolveConsent and streams the resultYes, in a later chunk, before hydration. The page renders without waiting.Stream consent
App Router, the page must arrive with its bannerRoot layout awaits resolveConsent inside SuspenseYes. The page waits for consent.Await consent
App Router with cacheComponents: trueEither layout above. Never await in the layout itself.Yes, with either layoutCache Components
Static, ISR or 'use cache' pagesBrowser, ConsentRoot without stateNoStatic, ISR and cached pages
Routes that set ensureStatic'shell' or 'prefetch': either App Router layout. 'navigation': browser, without stateSame as the layout you useKeep pages static with ensureStatic
Pages Router with getServerSidePropswithConsentProps from c15t/next/pagesYesPages Router
Pages Router pages without getServerSidePropsBrowser, through the same _app.tsx and the consent API routeNoPages Router
output: 'export'Browser, calling the backend directlyNoStatic export
A server app that should skip server work and local routesBrowser, calling backend /init with mode: hosted()NoClient-side initialization
Edge runtimeNot covered by an example or testNot coveredEdge 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.

Both App Router layouts call resolveConsent from c15t/next/server in the root layout. They differ in what waits for it.

LayoutWhat the visitor getsCost
Streamed, the defaultThe 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 SuspenseThe 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 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. 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:

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

ensureStaticStreamed layoutAwaited layoutBrowser layout
'shell'WorksWorks, but links show only the Suspense fallback before the clickWorks
'prefetch'WorksWorks, but links show only the Suspense fallback before the clickWorks
'navigation'Build failsBuild failsWorks

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.

SourceConfigUse it when
Build-time manifest, recommendedmanifest(), the default, with withConsentManifestYour app has a Next.js server and policy changes ship with a rebuild.
Cached manifestmanifest({ source: 'runtime' })Policy changes must apply without a rebuild.
Backend /inithosted()You want the fewest moving parts, or you export a static site.
Offlineoffline()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/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.

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

  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 cover the rest.