Skip to main content

Next.js

Quickstart

Before you start

This guide is for the App Router on a Next.js server, the setup most Next.js apps use. When you finish, your server works out each visitor's consent and sends it in the same response as the page. The browser makes no /init request, and analytics wait until consent allows them. Consent choices are saved to Inth.

Not using Next.js? Each framework has its own guide, including React with Vite and plain JavaScript. Pick yours from the framework list.

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.

This setup bundles your policy into the build. We recommend it for production, but it means you rebuild after you change policies, translations or vendors.

Install c15t

npm install c15t @c15t/integrations

You don't need to import a stylesheet, because the components add their own <style> elements. Tailwind CSS 3 is the exception. 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. The build, the server and the browser all see the same URL, and changing it on a deployed app does nothing 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/. That's outside your source tree, so there's nothing to add to .gitignore. resolveConsent finds the file on its own. When the build produced a snapshot, next start serves it without fetching the policy again.

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 browser bundle includes this file too, so keep secrets out of it. Scripts are fine.

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

The example holds PostHog back until measurement is allowed. Swap phc_your_project_key for your key, or use whichever integration your app needs. cookieless_mode: 'never' stops PostHog from switching to cookieless capture after a rejection. Remove any PostHog loader you already have in next/script or your tag manager. Otherwise PostHog loads twice, and the old copy ignores consent.

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 don't need a 'use client' wrapper. ConsentRoot already wraps ConsentProvider, so don't mount both.

Because the layout doesn't await resolveConsent(), Next.js sends the page without waiting for it and streams the consent result later in the same response. Until it arrives, the banner stays hidden and every optional category stays denied, so nothing loads early. If you want the banner in the first HTML, 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. Check the Network panel, not just the banner. A banner can appear while analytics still load.

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

local() keeps policy in your code and choices in the browser, with no consent records. Not recommended for production environments. See local 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.
  • shadcn/ui: use your own shadcn components for the banner and preferences.
  • Runnable Next.js example: this quickstart as an app you can run.