Skip to main content

Next.js

App Router

Before you start

This guide uses Inth 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 uses the same files with its own URL. Offline mode 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. For static, ISR or 'use cache' pages, Cache Components, or other rendering choices, start at rendering and deployment.

Location-based policies need trusted location headers from your host. Without them, c15t applies your unknown-location rule. See Forward geography headers before you deploy.

Install c15t and configure your backend

Create an Inth project, set its policy rules and add your site's origin to its trusted origins. Copy the project's backend URL.

npm install 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 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:

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

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. To let a build continue the same way, set onBuildError: 'runtime', or run it with C15T_ON_BUILD_ERROR=runtime:

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.

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

CommandDefault when the fetch fails
Production build: next build, vite build, nuxt build, astro buildThe build stops with an error.
Dev: next dev, vite dev, nuxt dev, astro devA 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:

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 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. Keep withConsentManifest, which also finds c15t.config.ts.

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

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.

OptionDefaultBehavior
backendURLNEXT_PUBLIC_C15T_BACKEND_URL, then NEXT_PUBLIC_INTH_PROJECT_URLBackend base URL. Choices post to ${backendURL}/subjects.
modemanifest()manifest(), hosted() or offline() from c15t/next. See consent modes.
routePrefixnoneWhere you mount the consent route. Set it only when some pages resolve consent in the browser.
proxyfalseSend browser saves through the consent route too. See optimization.
journey'page'The consent journey scope.
scripts, vendors, clearOnRevocation, networkBlocker, persistence, scriptLoader, optionsnoneBrowser 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 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.

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

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.

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.

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:

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

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:

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:

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.

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.

Use backend init instead of the manifest

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

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.

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

Keeping browser saves on your origin with proxy: true is optional with either source. 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 explains each vendor's loading and revocation behavior, and the consent checks cover the release checklist.