---
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-27T10:26:04Z"
lastAuthor: Christopher Burns
---
## 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. Most vendor helper scripts the visitor's consent allows
start before the page hydrates; see
[Rendering](/docs/frameworks/tanstack-start/rendering).

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 project](https://inth.com/dashboard/sign-up?framework=tanstack-start). 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 local mode.

The setup bundles your policy into the server at build time, the
recommended production setup. Rebuild after changing policies, translations
or vendors.

The adapter works with `@tanstack/react-start` 1.168.60 or a later 1.x
release, including TanStack Start 1.0, on React 18 or 19. Keep
`@tanstack/react-router` at the version your Start release depends on, or the
app loads two copies of the router. That's 1.170.41 for Start 1.168.60 and
1.170.43 for 1.169.0, the first Start 1.0 release. Later Start releases need
later routers.

If you're upgrading an existing app to TanStack Start 1.0, upgrade Node.js to
22.12 or later and Vite to 8 or later first. Start 1.0 also renames
`.inputValidator()` on `createServerFn()` and `createMiddleware()` to
`.validator()`, so rename any calls in your app. c15t doesn't call it.

## Install c15t

|Package manager|Command|
|:--|:--|
|npm|`npm install c15t @c15t/integrations`|
|pnpm|`pnpm add c15t @c15t/integrations`|
|yarn|`yarn add c15t @c15t/integrations`|
|bun|`bun add c15t @c15t/integrations`|

`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.
