Skip to main content

TanStack Start

Rendering and deployment

Pick your rendering path

Rendering choices change src/routes/__root.tsx. The recommended build-time manifest also adds a Vite plugin; the same-origin path adds one server route. Pages, components and hooks stay the same.

Your appRoot loaderBanner in the server responseSetup
Start server, recommendedAwaits consent from a build-time manifestYesQuickstart
Policy changes must apply without a rebuildAwaits consent from a runtime manifest cacheYesRuntime fetching
Runtime fetching on a serverless or often cold-started serverStreams consentYes, in a later chunk, before hydrationStream the page
Browser must only talk to your originAwaits consentYesSame-origin route
SPA mode, prerendered pages or a static hostNoneNo, the browser resolves consentNo server render

Bundle the manifest during builds

The quickstart and examples/tanstack-start use this setup.

The build fetches your public policy once and bundles it, so the server never fetches it at runtime.

Add the plugin before tanstackStart():

vite.config.ts
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import viteReact from '@vitejs/plugin-react';
import { consentManifest } from 'c15t/tanstack-start/build';
import { defineConfig } from 'vite';

export default defineConfig({
	plugins: [consentManifest(), tanstackStart(), viteReact()],
});

The plugin reads the backend URL from backendURL, or from VITE_C15T_BACKEND_URL, then VITE_INTH_PROJECT_URL, when you leave backendURL out. If VITE_C15T_BACKEND_URL is unset, the plugin sets it to the URL it used, so app code can read import.meta.env.VITE_C15T_BACKEND_URL instead of repeating the URL.

The plugin fetches the policy during vite build, or in vite dev when the server first loads it, and serves it to server code as snapshot from c15t/generated. In the browser bundle, snapshot is undefined, so the policy never ships to the browser. createConsentStateHandler() reads the snapshot and the backend URL from there, as the quickstart root route shows:

src/routes/__root.tsx
import { createServerFn } from '@tanstack/react-start';
import { createConsentStateHandler } from 'c15t/tanstack-start/server';

// Reads the backend URL and the policy consentManifest() downloaded.
const getConsentState = createServerFn({ method: 'GET' }).handler(
	createConsentStateHandler()
);

createConsentRoute() reads them the same way.

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.

vite build and vite dev fetch the manifest when they start. vite preview serves the last build without fetching. The plugin writes no file into your app, so there is nothing to keep out of Git. c15t/generated ships its own types, so tsc, vue-tsc and svelte-check pass on a fresh checkout without a build first.

The plugin can't see the options your app passes to manifest(). When the app passes source: 'runtime' or manifestURL, set consentManifest({ source: 'runtime' }) too. The build then downloads no manifest, so it doesn't fail when the backend's /manifest is down.

If the fetch fails, vite build stops. vite dev logs a warning instead, and snapshot is undefined, so the server fetches and caches the policy at runtime. To let a build continue the same way, set onBuildError: 'runtime', or run it with C15T_ON_BUILD_ERROR=runtime:

vite.config.ts
consentManifest({ onBuildError: 'runtime' }),

For policy updates without a rebuild, pass mode: manifest({ source: 'runtime' }) to createConsentStateHandler and keep the plugin, which still supplies the backend URL. See runtime fetching.

The quickstart root loader awaits the consent server function. The server reads the visitor's cookie and location headers, resolves their policy from the build-time snapshot, and renders the banner into the HTML. To apply policy updates without rebuilding, pass mode: manifest({ source: 'runtime' }) to createConsentStateHandler, with manifest from c15t/tanstack-start. The server then fetches the manifest and caches it in memory. Keep the plugin: it still supplies the backend URL.

createConsentStateHandler({ mode }) takes manifest(), hosted() or offline() from c15t/tanstack-start. They are plain data, so the state carries the mode to ConsentRoot, and the browser loads only that mode's code. A mode's snapshot stays on the server. Consent modes compares them.

With runtime fetching, the first request after a server starts has no cached manifest and waits for the backend. The server function waits at most 500 ms, then renders without consent UI and lets the browser resolve consent. Later requests read the cached manifest and add little time. Change the wait with timeoutMs in createConsentStateHandler.

With runtime fetching, return the pending server function call instead of awaiting it. TanStack Router streams the promise to the browser and ConsentRoot accepts it as state. This root fetches the manifest at runtime, and its loader does not await:

src/routes/__root.tsx
import {
	createRootRoute,
	HeadContent,
	Outlet,
	Scripts,
} from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
} from 'c15t/tanstack-start';
import {
	consentLoaderOptions,
	createConsentStateHandler,
} from 'c15t/tanstack-start/server';

import { scripts } from '../scripts';

// Declare the server function in your own module. Start's compiler splits
// the server code out of the browser bundle at this call site.
const getConsentState = createServerFn({ method: 'GET' }).handler(
	createConsentStateHandler()
);

const RootComponent = () => {
	const { consent } = Route.useLoaderData();
	return (
		<html lang="en">
			<head>
				<HeadContent />
			</head>
			<body>
				<ConsentRoot state={consent} scripts={scripts}>
					<Outlet />
					<ConsentBanner />
					<ConsentDialog />
					<footer>
						<ConsentDialogLink>Privacy settings</ConsentDialogLink>
					</footer>
				</ConsentRoot>
				<Scripts />
			</body>
		</html>
	);
};

export const Route = createRootRoute({
	...consentLoaderOptions,
	component: RootComponent,
	head: () => ({
		meta: [
			{ charSet: 'utf-8' },
			{ content: 'width=device-width, initial-scale=1', name: 'viewport' },
		],
	}),
	// Not awaited: the page streams without waiting for consent, and
	// ConsentRoot accepts the pending promise.
	loader: () => ({ consent: getConsentState() }),
});

The response starts without waiting for the backend. What changes for the visitor:

  • The page streams at once. The banner follows in a later chunk of the same response, before hydration. Pass streamBanner: false in ConsentRoot's options to mount it after hydration instead.
  • Until then every optional category is denied, so gated scripts and embeds stay blocked. A returning visitor's stored choice applies when the state arrives.
  • If the backend does not answer in time, the promise resolves to the cookie and header state and the browser resolves consent itself.

Stream when a slow or unreachable backend must never delay your page, for example on serverless hosts where many requests start with an empty manifest cache. Keep the awaited loader when the banner should arrive with the page.

By default the browser calls your backend URL directly. To keep every consent request on your own origin, mount the consent route:

src/routes/api/c15t/$.ts
import { createFileRoute } from '@tanstack/react-router';
import { createConsentRoute } from 'c15t/tanstack-start/api';

export const Route = createFileRoute('/api/c15t/$')({
	server: { handlers: createConsentRoute({ proxy: true }) },
});

Then give the server function the route's prefix, and proxy: true so the browser saves through the route too:

src/routes/__root.tsx
import {
	createRootRoute,
	HeadContent,
	Outlet,
	Scripts,
} from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
} from 'c15t/tanstack-start';
import {
	consentLoaderOptions,
	createConsentStateHandler,
} from 'c15t/tanstack-start/server';

import { scripts } from '../scripts';

// Declare the server function in your own module. Start's compiler splits
// the server code out of the browser bundle at this call site. The browser
// sends init and saves to the consent route in src/routes/api/c15t/$.ts.
const getConsentState = createServerFn({ method: 'GET' }).handler(
	createConsentStateHandler({ proxy: true, routePrefix: '/api/c15t' })
);

const RootComponent = () => {
	const { consent } = Route.useLoaderData();
	return (
		<html lang="en">
			<head>
				<HeadContent />
			</head>
			<body>
				<ConsentRoot state={consent} scripts={scripts}>
					<Outlet />
					<ConsentBanner />
					<ConsentDialog />
					<footer>
						<ConsentDialogLink>Privacy settings</ConsentDialogLink>
					</footer>
				</ConsentRoot>
				<Scripts />
			</body>
		</html>
	);
};

export const Route = createRootRoute({
	...consentLoaderOptions,
	component: RootComponent,
	head: () => ({
		meta: [
			{ charSet: 'utf-8' },
			{ content: 'width=device-width, initial-scale=1', name: 'viewport' },
		],
	}),
	loader: async () => ({ consent: await getConsentState() }),
});

What the route serves:

RequestHandled by
GET /api/c15t/init or GET /api/c15tThe route, from the bundled manifest, for this request's location, language and privacy signal
GET /api/c15t/manifestThe route, serving the bundled manifest
POST /api/c15t/subjects and other consent pathsForwarded to your backend because proxy: true
Any other path404, so the route is never an open proxy

Keep these details right:

  • createConsentStateHandler keeps the absolute backend URL from consentManifest() for its own requests. It never fetches under routePrefix, so the render cannot call its own route.
  • routePrefix puts the prefix on the state, so ConsentRoot sends the browser's init to /api/c15t/init and binds each save to the policy it resolved. It also makes the page load IAB vendor lists through the route. It means the same as Next.js defineConsentConfig({ routePrefix }), and like there it has no default. '/' throws @c15t/tanstack-start: `routePrefix` can't be '/': a consent route at the site root would catch every page. Use a path such as '/api/c15t'. when the handler is created. An empty string throws too, because it isn't a path that starts with /. To run without a consent route, leave routePrefix out.
  • proxy: true on createConsentStateHandler sends saves through the route, like Next.js defineConsentConfig({ proxy: true }). It needs routePrefix, and throws @c15t/tanstack-start: `proxy` sends saves through the consent route, so it needs `routePrefix`. without it. With hosted({ backendURL }), the browser still goes through the route; only the server asks that URL, so pass the same URL to createConsentRoute({ backendURL }). To send only init through the route and keep saves going straight to your backend, drop proxy: true from both and keep routePrefix.
  • The proxy forwards the visitor's user agent, language, origin and location headers so the backend's firewall sees a normal visitor. Set trustForwardedHeaders: true on createConsentRoute only when your host overwrites x-forwarded-for, as Vercel and Cloudflare do. Otherwise a visitor could send a false IP address to your backend.

SPA mode, prerendered pages and static hosts

A prerendered page is the same HTML for every visitor, so it cannot contain one visitor's consent. Drop the loader, pass an empty state, and let the browser resolve consent from the backend URL consentManifest() read:

src/routes/__root.tsx
import {
	createRootRoute,
	HeadContent,
	Outlet,
	Scripts,
} from '@tanstack/react-router';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
	consentPrefetchHead,
} from 'c15t/tanstack-start';

import { scripts } from '../scripts';

const backendURL =
	import.meta.env.VITE_C15T_BACKEND_URL ?? 'https://your-project.inth.app';

// No loader: the HTML is built ahead of time, so the browser resolves
// consent after it loads, from the backend URL consentManifest() read.
const RootComponent = () => (
	<html lang="en">
		<head>
			<HeadContent />
		</head>
		<body>
			<ConsentRoot state={{}} scripts={scripts}>
				<Outlet />
				<ConsentBanner />
				<ConsentDialog />
				<footer>
					<ConsentDialogLink>Privacy settings</ConsentDialogLink>
				</footer>
			</ConsentRoot>
			<Scripts />
		</body>
	</html>
);

export const Route = createRootRoute({
	component: RootComponent,
	head: () => ({
		meta: [
			{ charSet: 'utf-8' },
			{ content: 'width=device-width, initial-scale=1', name: 'viewport' },
		],
		// Optional: start the /init request while the browser parses <head>.
		...consentPrefetchHead({ backendURL }),
	}),
});
  • state={{}} means no server state. The page renders without consent UI and every optional category stays denied until the browser has the policy. ConsentRoot takes the backend URL from consentManifest(); without the plugin, an empty state throws, because there is no backend to ask. Pass state={{ mode: offline() }} to resolve without one.
  • The browser sends its /init request straight to your backend URL, so the host needs no server route or server function. Add the site's origin to your Inth project's trusted origins.
  • consentPrefetchHead is optional. It adds an inline script to <head> that starts the /init request before the app's JavaScript loads, and ConsentRoot uses that response instead of sending its own. If c15t assigns a banner experiment's arm, pass the experiment too: consentPrefetchHead({ backendURL, experiment }). Without it, ConsentRoot sends a second /init for visitors who have not chosen yet.

Drop the consent loader from a prerendered root. A loader that stays anyway is safe: while TanStack Start prerenders (TSS_PRERENDERING), resolveConsent treats the render as shared. It reads no cookie or location header, returns no stored consent, clock or GPC signal, and makes no manifest request, so no build-time state is baked into the HTML. Pass shared: true to force the same for other HTML you cache for every visitor. To prerender every page, configure the Vite plugin as tanstackStart({ prerender: { enabled: true } }) and serve the output as static files. SPA mode renders the same root route into its shell.

c15t/tanstack-start/static is a lower-level alternative that bundles the manifest at build time. createStaticConsentResolver({ manifest, geoURL }) gives first paint the manifest's unknown-location policy, its fallback rule or else its default rule, then the visitor's regional policy once geoURL answers. You write the consent transport yourself, and new policy needs a rebuild. A manifest with neither rule resolves to a failed insufficient-inputs result.

Run on hosts that stop work after the response

After a render, the server function reports the visitor's session to the backend in the background. With runtime fetching, the manifest cache also refreshes in the background after its s-maxage. Some serverless runtimes stop background work once the response is sent. Pass your platform's waitUntil as onBackgroundRevalidate to createConsentStateHandler and createConsentRoute so that work finishes. The promise never rejects.

Check the rendering path

  1. View the page source in a private window under a policy that asks for a choice. An awaited loader includes an element with data-testid="consent-banner-root", and so does a streamed loader. A prerendered page does not.
  2. Filter DevTools Network by subjects and reject. The POST goes to /api/c15t/subjects on your origin with the same-origin route, and to your backend URL otherwise.
  3. Vendor requests stay absent until you allow their category, on every path.