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
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():
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:
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:
| 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:
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.
Resolve consent in the root route
Replace or merge src/routes/__root.tsx:
What each part does:
getConsentStateruns on the server.createConsentStateHandler()takes no options here: it usesmanifest(), the default mode, and reads the backend URL and the snapshot fromconsentManifest(). It reads thec15tcookie and the location headers your host or CDN adds, such ascf-ipcountryorx-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 thecreateServerFn()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'sc15t/generatedexportssnapshot: 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/servercarries onlyconsentLoaderOptions; its other helpers throw if browser code calls them.- The loader awaits the result.
consentLoaderOptionsstops the loader from running again on client-side navigation, since the visitor's consent state does not change between routes. ConsentRootcreates 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, soConsentRootneeds onlystate. 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.scriptsstays 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. Replacephc_your_project_keywith your PostHog project key; PostHog uses its EU host, so addregion: 'us'for a US project. Remove any other PostHog loader from your app so PostHog loads once.- No stylesheet is linked.
ConsentBannerrenders its rules into the server HTML, andConsentDialogadds its own when it opens.
Gate your own features
Read a category's permission with useConsent from c15t/tanstack-start:
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.
- Server HTML. View the page source. It contains an element with
data-testid="consent-banner-root". - Before a choice. There are no requests to
posthog.com. - Reject All. The banner closes and the vendor requests stay absent.
- Reload and view source again. The HTML no longer contains the banner,
because the server read the stored rejection from the
c15tcookie. - Privacy settings. The footer link opens the dialog with Analytics (the
measurementcategory) and Marketing off. Turn on Analytics and save. PostHog'sarray.jsloads. - 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
- Rendering for streaming, SPA mode, prerendering and a same-origin consent route.
- Scripts and embeds for more vendors, iframes and network blocking.
- Customize for colors, fonts, layout and copy.
- Components and hooks.
- The runnable app in
examples/tanstack-startof the c15t repository contains this setup.