---
title: Quickstart
description: Resolve consent on the server in a TanStack Start root loader,
  render the banner in the first HTML, and gate vendor scripts, using Inth as
  the consent backend.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## 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](/docs/frameworks/tanstack-start/rendering).

You need an [Inth](https://inth.com) 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](/docs/concepts/choose-your-setup#decide-who-runs-the-consent-backend)
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

|Package manager|Command|
|:--|:--|
|npm|`npm install c15t@alpha @c15t/integrations@alpha`|
|pnpm|`pnpm add c15t@alpha @c15t/integrations@alpha`|
|yarn|`yarn add c15t@alpha @c15t/integrations@alpha`|
|bun|`bun add 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()`:

```ts title="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:

```sh title=".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](/docs/concepts/modes#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:

```sh
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](/docs/concepts/modes#what-happens-when-the-download-fails)
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](/docs/frameworks/tanstack-start/rendering#await-consent-in-the-loader).

## Resolve consent in the root route

Replace or merge `src/routes/__root.tsx`:

```tsx title="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](/docs/frameworks/tanstack-start/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`:

```tsx title="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](/docs/concepts/how-consent-works#a-permission-is-not-a-recorded-choice)
before you record or report choices. For iframes, use `ConsentGate`; see
[scripts and embeds](/docs/frameworks/tanstack-start/scripts).

## 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](/docs/frameworks/tanstack-start/troubleshooting) covers the
common failures. [Verify consent](/docs/guides/verify-consent) has the full
release checklist.

## Next steps

* [Rendering](/docs/frameworks/tanstack-start/rendering) for streaming, SPA mode,
  prerendering and a same-origin consent route.
* [Scripts and embeds](/docs/frameworks/tanstack-start/scripts) for more vendors,
  iframes and network blocking.
* [Customize](/docs/frameworks/tanstack-start/customize) for colors, fonts, layout
  and copy.
* [Components](/docs/frameworks/tanstack-start/components) and
  [hooks](/docs/frameworks/tanstack-start/hooks).
* The runnable app in `examples/tanstack-start` of the
  [c15t repository](https://github.com/c15t/c15t) contains this setup.
