---
title: App Router
description: Set up c15t in the Next.js App Router with Inth, a build-time
  policy manifest, consent-gated scripts and a streamed or awaited root layout.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Before you start

This guide uses [Inth](https://inth.com) for policies and consent records. Your
Next.js build bundles the project's public policy manifest. The server resolves consent
for each visitor. The browser sends consent choices to Inth.

A [self-hosted backend](/docs/self-host/quickstart) uses the same files with
its own URL. [Offline mode](/docs/frameworks/next/data-fetching-reference#offline-configuration)
keeps policy in your code and choices in the browser, with no consent records.
Not recommended for production environments.

This setup needs a Next.js server. For `output: 'export'`, follow
[static export](/docs/frameworks/next/static-export). For static, ISR or
`'use cache'` pages, Cache Components, or other rendering choices, start at
[rendering and deployment](/docs/frameworks/next/rendering).

Location-based policies need trusted location headers from your host. Without
them, c15t applies your unknown-location rule. See
[Forward geography headers](/docs/frameworks/next/geography-headers) before
you deploy.

## Install c15t and configure your backend

[Create an Inth project](https://inth.com), set its policy rules and add your
site's origin to its trusted origins. Copy the project's backend URL.

|Package manager|Command|
|:--|:--|
|npm|`npm install c15t@alpha @c15t/integrations@alpha`|
|pnpm|`pnpm add c15t@alpha @c15t/integrations@alpha`|
|yarn|`yarn add c15t@alpha @c15t/integrations@alpha`|
|bun|`bun add c15t@alpha @c15t/integrations@alpha`|

There is no c15t stylesheet to import. The components render their own rules
as `<style>` elements, so no stylesheet request holds back the first paint.
With Tailwind CSS 3, import `c15t/next/styles.css` yourself and set
`styles: false` in `ConsentRoot`'s `options`, as
[Customize](/docs/frameworks/next/customize#load-the-stylesheet-yourself)
shows.

## Set the backend URL

Set `NEXT_PUBLIC_C15T_BACKEND_URL` to your project's backend URL, including
any path prefix, in `.env.local` and in your host's build settings:

```sh title=".env.local"
NEXT_PUBLIC_C15T_BACKEND_URL=https://your-project.inth.app
```

If your app already sets `NEXT_PUBLIC_INTH_PROJECT_URL` for other Inth SDKs, that works
too. When both are set, `NEXT_PUBLIC_C15T_BACKEND_URL` wins.

Next.js inlines `NEXT_PUBLIC_` variables at build time, so the build, the
server and the browser use the same URL, and changing the variable on a built
app has no effect until you rebuild. To set the URL in code instead, pass
`backendURL` to `defineConsentConfig` and to `withConsentManifest`.

## Bundle the manifest during builds

The build fetches your public policy once and bundles it, so the server never
fetches it at runtime.

Wrap your Next.js config in `withConsentManifest`. An existing config object
or function goes in as the first argument, unchanged:

```ts title="next.config.ts"
import { withConsentManifest } from 'c15t/next/build';
import type { NextConfig } from 'next';

const nextConfig = {} satisfies NextConfig;

// Reads NEXT_PUBLIC_C15T_BACKEND_URL, like defineConsentConfig.
export default withConsentManifest(nextConfig);
```

`next build`, `next dev` and `next typegen` fetch `${backendURL}/manifest` and
write the policy to `node_modules/.cache/c15t/`, outside your source tree, so
there is nothing to keep out of Git. `next start` serves the built snapshot and
fetches no policy. Without a `backendURL` option, the build reads the
`backendURL` in `c15t.config.ts`, which defaults to
`NEXT_PUBLIC_C15T_BACKEND_URL`, then `NEXT_PUBLIC_INTH_PROJECT_URL`, so saves
and the snapshot point at the same
project.

The wrapper reads `c15t.config.ts` the way Next.js reads `next.config.ts`, and
skips the fetch for `mode: hosted()`, `mode: offline()`,
`manifest({ snapshot })` and `manifest({ source: 'runtime' })`, which read no
build-time manifest. Those builds never contact the backend. If the file can't
be read at build time, for example because it imports something Node can't
load, the build warns and fetches as for `manifest()`.

The wrapper also finds `c15t.config.ts` (or `.mts`, `.js`, `.mjs`) at the
project root, next to `next.config.ts`, and points `c15t/generated` at the
cache, in Turbopack and webpack. `ConsentRoot`, `resolveConsent` and the
consent route read both without an import. Server code that needs the
snapshot itself imports `snapshot` from `c15t/generated`. Only server
bundles get the snapshot: in a browser bundle,
`c15t/generated` imports `server-only`, so importing it from a client
component fails the build. The wrapper also adds `c15t`, `@c15t/core` and
`@c15t/nextjs` to `transpilePackages`, so Pages Router server bundles see the
aliases. A package you list in `serverExternalPackages` stays external: it
reads no `c15t.config.ts` and fetches the policy at runtime.

If the fetch fails, `next build` stops. `next dev` logs a warning instead, and
`snapshot` is `undefined`, so the server fetches and caches the policy at
runtime, as with
[runtime manifest caching](/docs/frameworks/next/optimization#reuse-cached-policy-data).
To let a build continue the same way, set `onBuildError: 'runtime'`, or run it
with `C15T_ON_BUILD_ERROR=runtime`:

```ts title="next.config.ts"
export default withConsentManifest(nextConfig, { onBuildError: 'runtime' });
```

A missing backend URL counts as a failed fetch. The build skips the fetch and
leaves `snapshot` `undefined` when the backend URL is relative, or when the
config sets `output: 'export'`, which has no server to use the snapshot. With
`onBuildError: 'fail'`, a relative URL stops the build. A static export always
skips the fetch.

The snapshot is fixed at build time:

* Rebuild after changing policies, translations or vendors. If your CI caches
  build output, force a fresh build.
* Consent choices still go to the backend.

The build reads the backend URL from your public backend URL variable when
the config doesn't pass one: `NEXT_PUBLIC_C15T_BACKEND_URL` in Next.js,
`NUXT_PUBLIC_C15T_BACKEND_URL` in Nuxt, `PUBLIC_C15T_BACKEND_URL` in Astro,
Svelte and SvelteKit, and `VITE_C15T_BACKEND_URL` in TanStack Start and other
Vite apps. Each also reads the matching Inth variable, such as
`NEXT_PUBLIC_INTH_PROJECT_URL`, when the c15t one is unset. See
[set the backend URL](/docs/concepts/modes#set-the-backend-url).

The fetch waits at most 10 seconds. When it fails, or no backend URL is set,
every framework does the same thing:

|Command|Default when the fetch fails|
|--|--|
|Production build: `next build`, `vite build`, `nuxt build`, `astro build`|The build stops with an error.|
|Dev: `next dev`, `vite dev`, `nuxt dev`, `astro dev`|A warning, and the server fetches the policy at runtime.|

Set `onBuildError` to use one behaviour for both. `'fail'` stops dev too.
`'runtime'` lets a production build finish, and the server fetches the policy
at runtime. The `C15T_ON_BUILD_ERROR` environment variable overrides the
option, so you can deploy during a backend outage without a code change:

```sh
C15T_ON_BUILD_ERROR=runtime npm run build
```

Turborepo's strict environment mode hides undeclared variables from tasks, so
list `C15T_ON_BUILD_ERROR` in the build task's `passThroughEnv` there.

The build skips the fetch, without an error, when it can't use a snapshot,
for example when the backend URL is relative. With `onBuildError: 'fail'`, a
relative URL stops the build. [Consent modes](/docs/concepts/modes#what-happens-when-the-download-fails)
lists every case.

To apply policy edits without a rebuild, set
`mode: manifest({ source: 'runtime' })` in `c15t.config.ts`. The server then
uses [runtime manifest caching](/docs/frameworks/next/optimization#reuse-cached-policy-data).
Keep `withConsentManifest`, which also finds `c15t.config.ts`.

## Add the consent config

Create `c15t.config.ts` at the project root, next to `next.config.ts`:

```ts title="c15t.config.ts"
import { posthog } from '@c15t/integrations/posthog';
import { defineConsentConfig } from 'c15t/next';

export default defineConsentConfig({
	scripts: [
		posthog({
			id: 'phc_your_project_key',
			initOptions: { cookieless_mode: 'never' },
			loadMode: 'after-consent',
		}),
	],
});
```

`withConsentManifest` finds the file, so `ConsentRoot`, `resolveConsent` and
the consent route read it without an import. Export the config as the file's
default export. The file is bundled into the browser as well as the server,
so it can hold `scripts` but must hold no secrets.

|Option|Default|Behavior|
|--|--|--|
|`backendURL`|`NEXT_PUBLIC_C15T_BACKEND_URL`, then `NEXT_PUBLIC_INTH_PROJECT_URL`|Backend base URL. Choices post to `${backendURL}/subjects`.|
|`mode`|`manifest()`|`manifest()`, `hosted()` or `offline()` from `c15t/next`. See [consent modes](/docs/concepts/modes).|
|`routePrefix`|none|Where you mount the [consent route](#initialize-in-the-browser). Set it only when some pages resolve consent in the browser.|
|`proxy`|`false`|Send browser saves through the consent route too. See [optimization](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin).|
|`journey`|`'page'`|The consent journey scope.|
|`scripts`, `vendors`, `clearOnRevocation`, `networkBlocker`, `persistence`, `scriptLoader`, `options`|none|Browser options. `ConsentRoot` props of the same name win over them.|

PostHog waits for measurement consent here. Replace `phc_your_project_key`
with your project key, or use the [integration](/docs/integrations/overview)
your application needs. `cookieless_mode: 'never'` disables cookieless capture
after rejection. Remove any existing PostHog loader, including `next/script`
and tag-manager entries, so the integration loads once.

## Start consent resolution in the root layout

Render `ConsentRoot` in the root layout and pass it `resolveConsent()` without
awaiting it:

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

import './globals.css';

const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			{/* Not awaited: the page renders while consent resolves. */}
			<ConsentRoot state={resolveConsent()}>
				{children}
				<ConsentBanner />
				<ConsentDialog />
				<footer>
					<ConsentDialogLink>Privacy settings</ConsentDialogLink>
				</footer>
			</ConsentRoot>
		</body>
	</html>
);

export default RootLayout;
```

The layout is a Server Component. `c15t/next` gives it `ConsentRoot`,
`ConsentBanner`, `ConsentDialog` and `ConsentDialogLink` as client components,
so you write no `'use client'` wrapper. `ConsentRoot` already wraps
`ConsentProvider`, so don't mount both.

`resolveConsent` reads the visitor's cookies and location headers and resolves
the policy from the bundled manifest. Next.js renders the page without waiting
and sends the result later in the same response. When the resolved policy
shows a banner, `ConsentBanner` renders on the server and arrives in that later
chunk, so it is visible before the page hydrates. Until the browser applies the
result after hydration, the dialog stays hidden, optional categories stay
denied, `ConsentGate` shows its placeholder and gated scripts do not load. The
browser makes no `/init` request.

To mount the banner after hydration instead, pass `streamBanner: false` in
`ConsentRoot`'s `options`.

A new visitor then sees the banner. A returning visitor with a valid stored
choice sees none, and the scripts that choice allows load. A choice that has
expired, was recorded under a different policy, or does not cover every
required category prompts again.

If the snapshot is missing and the runtime manifest request fails or takes
longer than `timeoutMs`, 500 ms by default, `resolveConsent` returns a
baseline state without a policy. The page has already rendered. The browser
retries after hydration, and gated scripts and embeds stay blocked until a
policy resolves. Pass `onError` to report these failures; see
[troubleshooting](/docs/frameworks/next/troubleshooting).

The layout calls `next/headers`, so routes under it render per request. With
`cacheComponents: true` the layout stays synchronous, and Next.js keeps the
page in the prerendered static shell. See
[Cache Components](/docs/frameworks/next/rendering#use-cache-components).

## Render the banner in the server HTML

To send the banner with the HTML, await `resolveConsent` in an async Server
Component and mount it inside `Suspense`:

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

import './globals.css';

const ResolvedConsent = async ({ children }: { children: ReactNode }) => {
	const state = await resolveConsent();

	return (
		<ConsentRoot state={state}>
			{children}
			<ConsentBanner />
			<ConsentDialog />
			<footer>
				<ConsentDialogLink>Privacy settings</ConsentDialogLink>
			</footer>
		</ConsentRoot>
	);
};

const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			<Suspense fallback={null}>
				<ResolvedConsent>{children}</ResolvedConsent>
			</Suspense>
		</body>
	</html>
);

export default RootLayout;
```

The server renders the consent subtree after the policy resolves, so the
banner arrives already decided and does not change after hydration. The page
content waits with it. Next.js streams an empty shell first and the page in a
later chunk of the same response. With runtime fetching, a slow manifest response keeps the page
blank for up to `timeoutMs`. If the request fails, the page renders without a
banner, optional categories stay denied, and the browser resolves the policy
after hydration.

The `Suspense` boundary is required with `cacheComponents: true`. Awaiting
request data in the root layout without it fails the build.
[Stream or await consent](/docs/frameworks/next/rendering#stream-or-await-consent-in-the-app-router)
compares the two layouts.

The banner renders its rules into the server HTML. `ConsentDialog` adds the
dialog's rules with its lazily loaded code, so they are not part of the first
page load.

## Initialize in the browser

Static, ISR and `'use cache'` pages are shared between visitors, so the server
cannot resolve consent into them. Render `ConsentRoot` without `state` in their
root layout:

```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`, the browser resolves consent after hydration. Give it a
route on your origin that resolves the bundled policy with the browser
request's location headers. Set `routePrefix` in `c15t.config.ts`:

```ts title="c15t.config.ts"
import { defineConsentConfig } from 'c15t/next';

// The backend URL comes from NEXT_PUBLIC_C15T_BACKEND_URL.
export default defineConsentConfig({ routePrefix: '/api/c15t' });
```

Then mount one catch-all route under it:

```ts title="app/api/c15t/[...c15t]/route.ts"
import { createConsentRoute } from 'c15t/next/api';

export const { GET } = createConsentRoute();
```

`createConsentRoute()` answers `GET /api/c15t/init` and
`GET /api/c15t/manifest` from the bundled snapshot. Any other path under
`/api/c15t` returns 404. To keep saves on your origin too, set `proxy: true`
in `c15t.config.ts` and pass `createConsentRoute({ proxy: true })`, which
forwards the remaining consent paths, such as `/subjects`, to the backend; see
[optimization](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin).

Without `routePrefix`, a page without `state` calls `${backendURL}/init`. The
HTML is the same for every visitor either way. To mix these pages with
server-rendered ones, see
[static, ISR and cached pages](/docs/frameworks/next/rendering#render-static-isr-and-cached-pages).

## Use backend init instead of the manifest

Set `mode: hosted()` in `c15t.config.ts`:

```ts title="c15t.config.ts"
import { defineConsentConfig, hosted } from 'c15t/next';

export default defineConsentConfig({ mode: hosted() });
```

Keep `withConsentManifest` in `next.config.ts`: it is what finds
`c15t.config.ts`. With `hosted()` it downloads no manifest, so `next build`
does not contact the backend. The layouts stay the same. `resolveConsent`
and the browser now call `${backendURL}/init`, and consent choices still go to
`${backendURL}/subjects`. Every visit makes a backend request, which the
manifest avoids. For a browser-only setup with no server work, follow
[client-side initialization](/docs/frameworks/next/client-side).

To keep `manifest()` but apply policy edits without a rebuild, use
`manifest({ source: 'runtime' })`. The server then fetches and caches the
policy, as
[runtime manifest caching](/docs/frameworks/next/optimization#reuse-cached-policy-data)
describes.

Keeping browser saves on your origin with
[`proxy: true`](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin) is optional with
either source. [Optimization](/docs/frameworks/next/optimization) also covers
manifest cache refresh and measured performance.

## Verify the setup

Start the production build with `next build` and `next start`, then open the
site in a fresh browser session with DevTools open.

1. Under an opt-in policy, the banner appears and the Network panel shows no
   PostHog request.
2. Click **Reject All**, then reload. The banner stays closed and PostHog does
   not load.
3. Open **Privacy settings** in the footer and turn on **Analytics** (the
   `measurement` category). PostHog loads.
4. Open **Privacy settings** again and turn **Analytics** off. The page reloads
   and PostHog does not load again.
5. The browser makes no `/init` request, and choices post to
   `${backendURL}/subjects`.

Test a new visitor and two locations. Requests without location headers
follow your unknown-location rule. [Script loading](/docs/frameworks/next/scripts)
explains each vendor's loading and revocation behavior, and
[the consent checks](/docs/guides/verify-consent) cover the release checklist.
