---
title: Quickstart
description: Add c15t consent management to a Next.js App Router app with Inth,
  a build-time policy manifest and consent-gated scripts, then check it works.
  Links to the Pages Router and static export guides.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Before you start

This quickstart sets up the App Router with a Next.js server, which is what
most Next.js apps use. Your build bundles the Inth project's public policy
manifest and your server resolves consent for each visitor, so the browser gets the answer
with the page and makes no `/init` request. Consent choices go to Inth.

Using the Pages Router, `output: 'export'` or cached pages? Find your app in
[Use another router or deployment](#use-another-router-or-deployment) first.

You need an [Inth](https://inth.com) project. Set its policy rules, add your
site's origin to its trusted origins, and copy its backend URL. The URL is
public configuration, not a secret. A
[self-hosted backend](/docs/self-host/quickstart) uses the same files with its
own URL.

The setup bundles your policy into the server at build time, the
recommended production setup. Rebuild after changing policies, translations
or vendors.

## Install c15t

|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 stylesheet to import. c15t's components render their own rules
as `<style>` elements. With Tailwind CSS 3, import the stylesheet
yourself and set `styles: false`, as
[Customize](/docs/frameworks/next/customize#load-the-stylesheet-yourself)
shows.

## Set the backend URL

Set 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://example-inth.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.

`https://example-inth.inth.app` is a demo project you can use to try the
setup. 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.

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

export default withConsentManifest(nextConfig);
```

`next build` and `next dev` fetch `${NEXT_PUBLIC_C15T_BACKEND_URL}/manifest`
and write the policy to `node_modules/.cache/c15t/`, outside your source tree.
`resolveConsent` reads it on its own, so you don't import it. `next start`
serves the built snapshot and fetches no policy. There is nothing to keep out
of Git.

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.

## Add the consent config

Create `c15t.config.ts` at the project root:

```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 this file, so `ConsentRoot` and `resolveConsent`
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.

The config sets no `mode`, so it uses `manifest()`: the server resolves each
visitor from the bundled policy. [Consent modes](/docs/concepts/modes) lists
the alternatives.

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.

## Resolve consent 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.

Next.js renders the page without waiting for consent and sends the result
later in the same response. Until the browser applies it, the banner stays
hidden and optional categories stay denied. To send the banner in the first
HTML instead, see
[render the banner in the server HTML](/docs/frameworks/next/app-router#render-the-banner-in-the-server-html).

## Check it works

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

If the banner does not appear, see
[why is there no banner](/docs/guides/troubleshooting#why-is-there-no-banner)
and [Next.js troubleshooting](/docs/frameworks/next/troubleshooting).
[Verify consent](/docs/guides/verify-consent) has the release checklist. A
visible banner alone does not prove that analytics wait for consent.

## Use another router or deployment

|Your app|Guide|
|--|--|
|App Router with a Next.js server|[App Router](/docs/frameworks/next/app-router), the full version of this quickstart|
|Pages Router with a Next.js server|[Pages Router](/docs/frameworks/next/pages-router)|
|`output: 'export'`, with either router|[Static export](/docs/frameworks/next/static-export)|
|Static, ISR or `'use cache'` pages, Cache Components, `ensureStatic`, or the banner in the first HTML|[Rendering and deployment](/docs/frameworks/next/rendering)|
|The fewest files, with consent resolved in the browser|[Client-side initialization](/docs/frameworks/next/client-side)|

`offline()` keeps policy in your code and choices in the browser, with no
consent records. Not recommended for production environments. See
[offline configuration](/docs/frameworks/next/data-fetching-reference#offline-configuration).

## Next steps

* [App Router](/docs/frameworks/next/app-router): the awaited layout, the
  consent route for static pages, and backend `/init` instead of the manifest.
* [Scripts](/docs/frameworks/next/scripts): more vendors and how each loads
  and revokes.
* [Customize](/docs/frameworks/next/customize): theme, layout and copy.
* [Runnable Next.js example](/docs/examples): this quickstart as an app you
  can run.
