Next.js
Pages 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 in getServerSideProps, through withConsentProps. 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.
Rendering and deployment compares every
path.
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.
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:
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.
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 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:
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.
withConsentProps() and createPagesConsentRoute() read the snapshot
through the same alias. The wrapper adds c15t, @c15t/core and
@c15t/nextjs to transpilePackages for this: the Pages Router leaves
dependencies external on the server otherwise, and an external package does
not see the alias.
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:
| 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:
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.
Add the consent config
Create c15t.config.ts at the project root, next to next.config.ts:
withConsentManifest finds the file, so ConsentRoot, withConsentProps and
the consent route read it without an import. 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(); see
consent modes.
routePrefix names the consent route below. Pages without
getServerSideProps resolve consent in the browser through it.
PostHog waits for measurement consent here. Replace phc_your_project_key
with your project key, or use the integration
your application needs.
Add the consent API route
Create one catch-all API route under routePrefix:
It answers GET /api/c15t/init and GET /api/c15t/manifest from the bundled
snapshot. With runtime fetching, it fetches ${backendURL}/manifest and
caches the public policy. Any other path under /api/c15t returns 404, and
methods other than GET and HEAD get 405. The file must be named
[...c15t].ts; for another name, pass the parameter as the second argument,
such as createPagesConsentRoute({}, 'path') for [...path].ts.
Mount one root for every page
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.
Render ConsentRoot in _app.tsx. It reads the state a page resolved on the
server from pageProps.consent:
A page with withConsentProps supplies consent. On any other page,
including static and getStaticProps pages, consent is undefined, and the
browser asks /api/c15t/init after hydration. That route resolves the
bundled policy with the browser request's location headers.
Resolve consent in getServerSideProps
Export withConsentProps() from c15t/next/pages as the page's
getServerSideProps:
It resolves the visitor's consent from c15t.config.ts and adds it to the
page's props as consent, with undefined fields removed, as Next.js
requires. The page renders the banner in the server HTML, and the browser
applies the same state without an /init request.
A page with its own getServerSideProps passes it in:
Consent resolves while the page's function runs, and its redirect and
notFound results pass through unchanged. resolveConsent options, such as
timeoutMs, go in the second argument.
Add withConsentProps to every page that should render consent on the
server. Do not resolve consent in getStaticProps. It runs without the
visitor's request, so it cannot read their cookies or location. If the
snapshot is missing and the runtime manifest request fails or takes longer
than timeoutMs, 500 ms by default, the page renders without a policy and the
browser retries after hydration.
Use backend init instead of the manifest
Set mode: hosted() in c15t.config.ts and remove routePrefix and
pages/api/c15t/[...c15t].ts. Keep withConsentManifest, which finds the
config. With hosted() it downloads no manifest, so next build does not
contact the backend. _app.tsx and the pages stay the same. withConsentProps and the browser now call
${backendURL}/init, and consent choices still go to
${backendURL}/subjects. For a browser-only setup with no server work, follow
client-side initialization.
Keeping browser saves on your origin with
proxy: true is optional with
either source.
Verify the setup
Start the production build with next build and next start, then open a
page that exports withConsentProps in a fresh browser session with DevTools
open.
- View the page source. It contains
data-testid="consent-banner-root". - Under an opt-in policy, the Network panel shows no PostHog request. Click Reject All and reload. The banner stays closed and PostHog does not load.
- Open Privacy settings and turn on Analytics (the
measurementcategory). PostHog loads. - Navigate to another page with a Next.js
Linkand reopen Privacy settings. The choice is still selected. - Open a static page directly. It shows the same choice, resolved in the
browser through
/api/c15t/init.
Pages with withConsentProps make no /init request, and choices post to
${backendURL}/subjects. Script loading
explains each vendor's loading and revocation behavior, and
the consent checks cover the release checklist.