Skip to main content

Next.js

Quickstart

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

You need an Inth 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 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

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

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

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.

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.

Create c15t.config.ts at the project root:

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 lists the alternatives.

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.

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.

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 and Next.js troubleshooting. Verify consent has the release checklist. A visible banner alone does not prove that analytics wait for consent.

Use another router or deployment

Your appGuide
App Router with a Next.js serverApp Router, the full version of this quickstart
Pages Router with a Next.js serverPages Router
output: 'export', with either routerStatic export
Static, ISR or 'use cache' pages, Cache Components, ensureStatic, or the banner in the first HTMLRendering and deployment
The fewest files, with consent resolved in the browserClient-side initialization

offline() keeps policy in your code and choices in the browser, with no consent records. Not recommended for production environments. See offline configuration.

Next steps

  • App Router: the awaited layout, the consent route for static pages, and backend /init instead of the manifest.
  • Scripts: more vendors and how each loads and revokes.
  • Customize: theme, layout and copy.
  • Runnable Next.js example: this quickstart as an app you can run.