Skip to main content

Next.js Verify and troubleshoot

Troubleshooting

Check. resolveConsent returns its baseline state when a manifest or backend request fails. The page can still render, and the client retries initialization. Until policy resolution succeeds, optional categories stay denied and the stock banner stays hidden. Check the failed URL and response in your server logs, as browser Network tools cannot show a server-side fetch.

Fix. Pass onError to resolveConsent to report failures in production. Without it, the helper logs a warning only outside production.

Check. If the consent route repeatedly calls itself, check its upstream URL.

Fix. Set the config's backendURL, or NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL), to the absolute Inth or self-hosted backend endpoint. To send the browser through /api/c15t, set proxy: true and routePrefix instead of a relative backendURL; the route keeps the absolute URL. See Next.js optimization.

Why does next build or next dev fail to fetch the manifest?

Check. withConsentManifest downloads ${backendURL}/manifest when next build, next dev or next typegen loads next.config.ts. When the download fails or takes longer than 10 seconds, next build stops with an error that starts with @c15t/nextjs/build: could not fetch the consent manifest from <url> during the build. next dev logs the same message as a warning, and snapshot from c15t/generated is undefined, so the server fetches the policy at runtime. It never reuses an old snapshot. The part in parentheses names the cause:

  • fetch failed, with ENOTFOUND, ECONNREFUSED or another network error, or no response within 10 seconds. The build machine cannot reach the backend.
  • /manifest responded 404, or another status. The URL is not your project's backend. Check for the https://your-project.inth.app placeholder and a missing path prefix.
  • /manifest returned an invalid consent manifest. The URL answered, but not with a c15t manifest. It may be a different service, or a backend that does not support this c15t version.

no backend URL is set means the build found no backendURL option, no backendURL in c15t.config.ts and no NEXT_PUBLIC_C15T_BACKEND_URL or NEXT_PUBLIC_INTH_PROJECT_URL, or empty ones. next build stops on it and next dev warns. A notice that the build skipped the consent manifest fetch means the backend URL is a path such as /api/c15t. With onBuildError: 'fail', that stops the build with build-time manifests require an absolute upstream URL. The same notice ending in because c15t.config.ts uses hosted(), uses offline(), passes manifest({ snapshot }) or sets manifest({ source: 'runtime' }) is expected: those modes read no build-time manifest.

@c15t/nextjs/build: could not read c15t.config.ts (...) is a warning: the build could not evaluate the file, so it fetched the manifest as for manifest(), whatever the file's mode. The part in parentheses is the error. The usual cause is an import Node can't load at build time, such as a stylesheet. Keep c15t.config.ts to the config and the packages it needs.

Fix. Set NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL) to the absolute backend URL from your Inth project, or your self-hosted origin with its path prefix, wherever next build runs, including CI. To deploy while the backend is down, run the build with C15T_ON_BUILD_ERROR=runtime, or set onBuildError: 'runtime'. The deployment then works without a snapshot, and each server fetches the policy on its first request. To drop the build-time fetch, use runtime manifest caching instead.

Why does importing c15t/generated from a client component fail the build?

Check. next build or next dev reports that server-only cannot be imported from a Client Component, in a module that imports c15t/generated.

Fix. withConsentManifest keeps the snapshot out of browser bundles, so c15t/generated only works in server code: Server Components, route handlers, getServerSideProps and API routes. Most apps never import it. resolveConsent and the consent route read the snapshot themselves, and ConsentRoot receives the resolved state.

Why is snapshot undefined?

Check. snapshot from c15t/generated is undefined in server code.

Fix. Check these causes:

  • withConsentManifest doesn't wrap the config in next.config.ts. Without it, c15t/generated still resolves, but exports undefined.
  • The fetch failed in next dev, or in a build with C15T_ON_BUILD_ERROR=runtime. The server then fetches the policy at runtime.
  • The backend URL is relative, or the config sets output: 'export', so the build skipped the fetch.
  • The code runs in a package listed in serverExternalPackages, which stays outside the bundle and doesn't see the alias.

c15t/generated ships its own types, so tsc --noEmit passes on a fresh clone without running Next.js first.

Verify manifest fetching

Check the Next.js server and browser requests after following the App Router or Pages Router guide:

  1. Load the page twice. A warm manifest setup resolves policy without calling the upstream backend /init. A browser request to your local /api/c15t/init is expected when client initialization is needed.
  2. Make a consent choice. Confirm the submission reaches the backend /subjects endpoint and reload to check that the choice persists.
  3. Test two locations with different configured policies. Confirm the resolved policy matches each location and that your host passes trusted geography headers to Next.js.
  4. Change a policy, rebuild, then check it. With runtime fetching, check it after the manifest caches refresh instead. Cache the public manifest, never the visitor-specific init response.
  5. If you set proxy: true, confirm browser consent requests use /api/c15t, including submissions. Direct requests to the backend are expected without it.

Why does ConsentRoot warn that it found no config?

Check. The browser console shows this warning outside production:

[c15t] ConsentRoot found no config. Export `defineConsentConfig()` as the default export of c15t.config.ts at the project root and wrap next.config.ts in `withConsentManifest()`, or pass `config`.

Fix. withConsentManifest finds c15t.config.ts, .mts, .js or .mjs in the directory next build and next dev run from, and nowhere else. A config in src/ is not found. Check that:

  • next.config.ts exports withConsentManifest(nextConfig).
  • The file is at the project root, next to next.config.ts.
  • It has a default export, export default defineConsentConfig({ ... }), not only a named one.
  • You restarted next dev after adding the file.

Without a config, ConsentRoot reads NEXT_PUBLIC_C15T_BACKEND_URL, which withConsentManifest sets from NEXT_PUBLIC_INTH_PROJECT_URL when only that is set, uses manifest() and runs no scripts. With no backend URL either, it throws; see the next section.

Why does ConsentRoot throw that it needs a backend URL?

Check. The page, or next build while it prerenders the page, throws one of these errors from ConsentRoot:

  • @c15t/nextjs: manifest() needs a backend URL. Set NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL), or `backendURL` in c15t.config.ts.
  • @c15t/nextjs: hosted() needs a backend URL. Set NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL), or `backendURL` in c15t.config.ts.

A production build prints only the first sentence, such as @c15t/nextjs: manifest() needs a backend URL.

ConsentRoot found no c15t.config.ts, or got a config prop without a backend URL, and neither NEXT_PUBLIC_C15T_BACKEND_URL nor NEXT_PUBLIC_INTH_PROJECT_URL was set when Next.js compiled the page.

Fix. Set NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL) wherever next build and next dev run, and check that withConsentManifest finds c15t.config.ts (see the previous section). Earlier alphas fell back to offline mode here; now the root throws. To run without a backend, set mode: offline() in c15t.config.ts, or pass options={{ mode: offline() }} to ConsentRoot.

Why does ConsentRoot warn that a transport ships in the first-load bundle?

Check. The browser console shows a warning such as:

[c15t] ConsentRoot `mode` is the hosted() transport itself, so its code ships in the first-load bundle. Use hosted() from c15t/next instead: it is plain data, and the root loads the code only when it runs.

Fix. options.mode received hosted(), offline() or manifest() from c15t/react, which are transports. Import the mode from c15t/next and set it as mode in c15t.config.ts. custom(transport) does not warn.

Why does defineConsentConfig throw?

Check. next build, next dev or the first server render fails with a TypeError that starts with @c15t/nextjs: defineConsentConfig. withConsentManifest reads c15t.config.ts when Next.js loads next.config.ts, so most config errors stop the build before it compiles. The checks run on the server only; the browser copy of the file skips them.

Fix. The config reference lists each message. The most common is @c15t/nextjs: defineConsentConfig needs `backendURL`, or NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL) set at build time. Set the variable in .env.local and in your host's build settings, or pass backendURL.

Why is the visitor's location unknown on the server?

Check. resolveConsent and the consent route read the visitor's country and region from hosting platform headers such as cf-ipcountry and x-vercel-ip-country; c15t never derives location from an IP address itself. Some platforms expose those headers to Next.js middleware or proxy but strip them before Server Components and Route Handlers run, so every visitor falls back to your unknown-location rule. Log (await headers()).get('x-vercel-ip-country'), or your host's equivalent, in a Server Component.

Fix. If the header is missing there, add c15tProxy as described in Forward geography headers. It forwards the resolved values as x-c15t-country and x-c15t-region, which take precedence everywhere c15t reads location.

Why does Next.js report an unstable Date.now() during prerendering?

Check. The error Next.js encountered the unstable value Date.now() while prerendering, pointing at the await resolveConsent(...) line, comes from partialPrefetching: true combined with cacheComponents: true (Next.js 16.3 and later). During the runtime-prefetch stage Next.js resolves headers() and then flags the Date.now() that follows. It only appears in next dev; next build passes because build-time prerenders never resolve headers().

Fix. Use the current c15t release. resolveConsent handles this itself: the default App Router request reader calls await connection() from next/server before reading the clock, so the component is already request-time when the clock is read. You do not need to add connection() to your layout. If you still see the error on the current release, report your Next.js version and config on c15t/c15t#1107.

Why does next build fail on a route with ensureStatic = 'navigation'?

Check. The build fails with one of these errors, and the route sits under a root layout that calls resolveConsent:

  • Next.js encountered data that is not available during a static prerender, but is unable to provide a location (streamed layout)
  • Next.js encountered uncached or runtime data on a route that must be fully static (awaited layout)

'navigation' requires the whole route to be static, including the root layout. resolveConsent reads the visitor's cookies and headers, so that layout can't be.

Fix. Move those pages into their own route group with the browser layout, which renders ConsentRoot without state. See Keep pages static with ensureStatic. 'shell' and 'prefetch' work with the server layouts.

Why does the page throw that no IABProvider is mounted?

The visitor's policy uses the iab model and the backend sent its vendor list, but no IABProvider is mounted. The standard banner and dialog do not handle the IAB model, so they throw rather than leave the visitor with no consent UI. Server and browser render the same error.

Fix it one of two ways:

  • If the site uses IAB TCF, render IABProvider with IABConsentBanner and IABConsentDialog from c15t/react/iab inside your consent provider. They can sit next to the standard banner.
  • If it does not, remove the iab model from the policy for that region in your Inth project or policy pack.

A backend that answers gvl: null turns IAB off for that request. The policy then runs as opt-in and nothing throws.

The check passes once c15t/react/iab has loaded. If you import the IAB components with a dynamic import(), a render of the standard banner that happens before the import finishes still throws.

Inspect the active policy

Place this diagnostic component inside your existing consent boundary. It reads state without recording a choice.

app/consent-policy-debug.tsx
'use client';

import { useSnapshot } from 'c15t/next';

export function ConsentPolicyDebug() {
	const snapshot = useSnapshot();
	const resolution = snapshot.resolution;

	return (
		<pre>
			{JSON.stringify(
				{
					status: resolution.status,
					policy: resolution.status === 'matched' ? resolution.policy.id : null,
					prompt: snapshot.promptRequirement,
					permissions: snapshot.effectivePermissions,
					location: snapshot.location,
					privacySignals: snapshot.privacySignals,
				},
				null,
				2
			)}
		</pre>
	);
}

The diagnostic subscribes to consent changes. Remove it after verification. For a production feature gate, use useConsent('marketing') or the category your feature needs instead of subscribing to the whole snapshot.

More help

Troubleshoot consent covers problems shared by every framework, such as a missing banner, analytics that load before a choice, imports that fail because npm installed c15t v2, choices that disappear on reload, server HTML that differs from the browser, static builds that fail and content blockers that hide the consent UI.