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 app | Root loader | Banner in the server response | Setup |
|---|---|---|---|
| Start server, recommended | Awaits consent from a build-time manifest | Yes | Quickstart |
| Policy changes must apply without a rebuild | Awaits consent from a runtime manifest cache | Yes | Runtime fetching |
| Runtime fetching on a serverless or often cold-started server | Streams consent | Yes, in a later chunk, before hydration | Stream the page |
| Browser must only talk to your origin | Awaits consent | Yes | Same-origin route |
| SPA mode, prerendered pages or a static host | None | No, the browser resolves consent | No 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():
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:
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:
| 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.
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:
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.
Await consent in the loader
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.
Stream the page while consent resolves
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:
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: falseinConsentRoot'soptionsto 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.
Keep consent requests on your origin
By default the browser calls your backend URL directly. To keep every consent request on your own origin, mount the consent route:
Then give the server function the route's prefix, and proxy: true so the
browser saves through the route too:
What the route serves:
| Request | Handled by |
|---|---|
GET /api/c15t/init or GET /api/c15t | The route, from the bundled manifest, for this request's location, language and privacy signal |
GET /api/c15t/manifest | The route, serving the bundled manifest |
POST /api/c15t/subjects and other consent paths | Forwarded to your backend because proxy: true |
| Any other path | 404, so the route is never an open proxy |
Keep these details right:
createConsentStateHandlerkeeps the absolute backend URL fromconsentManifest()for its own requests. It never fetches underroutePrefix, so the render cannot call its own route.routePrefixputs the prefix on the state, soConsentRootsends the browser's init to/api/c15t/initand 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.jsdefineConsentConfig({ 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, leaveroutePrefixout.proxy: trueoncreateConsentStateHandlersends saves through the route, like Next.jsdefineConsentConfig({ proxy: true }). It needsroutePrefix, and throws@c15t/tanstack-start: `proxy` sends saves through the consent route, so it needs `routePrefix`.without it. Withhosted({ backendURL }), the browser still goes through the route; only the server asks that URL, so pass the same URL tocreateConsentRoute({ backendURL }). To send only init through the route and keep saves going straight to your backend, dropproxy: truefrom both and keeproutePrefix.- The proxy forwards the visitor's user agent, language, origin and location
headers so the backend's firewall sees a normal visitor. Set
trustForwardedHeaders: trueoncreateConsentRouteonly when your host overwritesx-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:
state={{}}means no server state. The page renders without consent UI and every optional category stays denied until the browser has the policy.ConsentRoottakes the backend URL fromconsentManifest(); without the plugin, an empty state throws, because there is no backend to ask. Passstate={{ mode: offline() }}to resolve without one.- The browser sends its
/initrequest 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. consentPrefetchHeadis optional. It adds an inline script to<head>that starts the/initrequest before the app's JavaScript loads, andConsentRootuses that response instead of sending its own.
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
- 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. - Filter DevTools Network by
subjectsand reject. ThePOSTgoes to/api/c15t/subjectson your origin with the same-origin route, and to your backend URL otherwise. - Vendor requests stay absent until you allow their category, on every path.