Skip to main content

TanStack Start

Quickstart

Before you start

This guide sets up the recommended path for a TanStack Start app with server rendering. On each request, a server function in the root loader reads the visitor's consent cookie and location headers, resolves their policy from a build-time policy manifest, and hands the result to ConsentRoot. The banner is part of the first HTML, and gated scripts can run right after hydration.

It needs a running Start server. For SPA mode, prerendered pages or a static host, or to stream the page before consent resolves, see rendering.

You need an Inth project. In the project, set the policy rules for your regions, add your site's origin to the trusted origins, and copy the backend URL. Choose your setup covers self-hosting and offline mode.

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

c15t contains the TanStack Start adapter at c15t/tanstack-start. @c15t/integrations holds the vendor loaders used below.

Bundle your policy at build time

Add the consentManifest plugin to vite.config.ts, 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()],
});

During vite build, or in vite dev when the server first loads it, the plugin downloads your project's public policy from ${backendURL}/manifest and serves it to server code as snapshot from c15t/generated. In the browser bundle, snapshot is undefined, so the policy stays on the server. No file is written into your project.

Set VITE_C15T_BACKEND_URL to your project's backend URL, exactly as Inth shows it, including any path prefix, in .env or wherever you build:

.env
VITE_C15T_BACKEND_URL=https://your-project.inth.app

If your app already sets VITE_INTH_PROJECT_URL for other Inth SDKs, that works too. When both are set, VITE_C15T_BACKEND_URL wins.

The example app's .env points at https://example-inth.inth.app, a demo project. The server function and ConsentRoot read the URL from the plugin, so the app code never repeats it. If you pass backendURL to the plugin instead, the plugin sets the variable to that URL. Vite writes its value into the server and browser builds, so setting it when you start a built app has no effect.

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.

To apply policy edits without rebuilding, pass createConsentStateHandler({ mode: manifest({ source: 'runtime' }) }), with manifest from c15t/tanstack-start. The server function then fetches and caches the policy at runtime; see runtime fetching.

Replace or merge src/routes/__root.tsx:

src/routes/__root.tsx
import { posthog } from '@c15t/integrations/posthog';
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';

// Start's compiler keeps the handler and the bundled policy on the server.
const getConsentState = createServerFn({ method: 'GET' }).handler(
	createConsentStateHandler()
);

const scripts = [
	posthog({
		id: 'phc_your_project_key',
		initOptions: { cookieless_mode: 'never' },
		loadMode: 'after-consent',
	}),
];

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 each part does:

  • getConsentState runs on the server. createConsentStateHandler() takes no options here: it uses manifest(), the default mode, and reads the backend URL and the snapshot from consentManifest(). It reads the c15t cookie and the location headers your host or CDN adds, such as cf-ipcountry or x-vercel-ip-country, then resolves this visitor's policy from the bundled snapshot, so the render never waits on Inth. Geography headers lists the headers it reads. Declare the createServerFn() call in your own module, as shown. Start's compiler finds server code by that call site and removes it from the browser build. The browser build's c15t/generated exports snapshot: undefined, so the policy never ships to the browser. A server function built inside a package does not work. In the browser build, @c15t/tanstack-start/server carries only consentLoaderOptions; its other helpers throw if browser code calls them.
  • The loader awaits the result. consentLoaderOptions stops the loader from running again on client-side navigation, since the visitor's consent state does not change between routes.
  • ConsentRoot creates the consent runtime from that state, so the server HTML and the first browser render match. The state carries the backend URL, the mode and the route prefix, so ConsentRoot needs only state. The browser sends its requests, including consent saves, straight to the backend URL the server function used, so saves reach the project the snapshot came from.
  • scripts stays in this module. A server function can only return serializable data, and vendor loaders contain functions. PostHog waits for measurement permission, so your policy must include that category. Replace phc_your_project_key with your PostHog project key; PostHog uses its EU host, so add region: 'us' for a US project. Remove any other PostHog loader from your app so PostHog loads once.
  • No stylesheet is linked. ConsentBanner renders its rules into the server HTML, and ConsentDialog adds its own when it opens.

Gate your own features

Read a category's permission with useConsent from c15t/tanstack-start:

src/components/marketing-banner.tsx
import { useConsent } from 'c15t/tanstack-start';

export function MarketingBanner() {
	return useConsent('marketing') ? <aside>Spring sale</aside> : null;
}

useConsent returns the permission right now. Under an opt-out policy it can be true before the visitor has chosen anything. Read how consent works before you record or report choices. For iframes, use ConsentGate; see scripts and embeds.

Check that it works

Build the app with vite build, start the production server, and open the site in a private window with DevTools open on the Network tab. Test under a policy that asks for a choice, such as an EU opt-in policy. Your host must send a country header; locally, add country: 'DE' to the createConsentStateHandler options for the test only.

  1. Server HTML. View the page source. It contains an element with data-testid="consent-banner-root".
  2. Before a choice. There are no requests to posthog.com.
  3. Reject All. The banner closes and the vendor requests stay absent.
  4. Reload and view source again. The HTML no longer contains the banner, because the server read the stored rejection from the c15t cookie.
  5. Privacy settings. The footer link opens the dialog with Analytics (the measurement category) and Marketing off. Turn on Analytics and save. PostHog's array.js loads.
  6. Turn Analytics off again. The page reloads and PostHog does not load.

Troubleshooting covers the common failures. Verify consent has the full release checklist.

Next steps