TanStack quickstart
Get an existing TanStack Start app from nothing to a signed-in user, using the published @timonier/tanstack package. Part 1 creates a project and an app in your operator dashboard; Part 2 wires the SDK into the app.
Part 1 — create a project and an app
1. Sign in to the dashboard
Open your platform's operator dashboard. Sign-up and sign-in share one screen — if you don't have an operator account yet, entering your email there creates one.
Enter your email and click Sign in, then enter the 6-digit code from the email.
The dashboard walks you straight into creating your first project.
2. Create a project
From Projects, create a new project: give it a display name and a slug, then confirm.
3. Create an app
From the project's apps list, create a new app. Fill in:
- Display name and Slug — anything you like.
- Redirect URI 1 —
http://localhost:3000/api/timonier/callbackfor local development. This is where@timonier/tanstackreceives the sign-in callback. - Signup enabled — leave this checked (it's checked by default). Unchecking it makes the platform reject account creation entirely — including the sign-up route this guide has you add — so new visitors can never complete sign-up until you turn it back on.
Confirm, then save your credentials.
4. Get your three values
Open the app's Integration tab. You'll need three values from the .env.local block shown there for Part 2:
TIMONIER_PLATFORM_URL=<your platform URL>
TIMONIER_PROJECT_SLUG=<your project slug>
TIMONIER_APP_SLUG=<your app slug>
The tab derives the scheme for you when the hostname is
localhost,127.0.0.1, or::1, writinghttp://there. For any other hostname it always writeshttps://— if you're self-hosting on a real hostname and your data plane serves plain HTTP, change the scheme tohttp://yourself, or your app can't reach it.
Part 2 — wire up the app
This guide starts from an existing TanStack Start app. If you don't have one yet, follow TanStack Start's own quick start to scaffold one, then come back here.
5. Scope the registry
echo "@timonier:registry=https://npm.registry.timonier.eu/" >> .npmrc6. Install
npm install @timonier/tanstack7. Set the environment
TanStack Start runs on Vite, and Vite does not put .env values into server-side process.env — the only delivery path proven for this SDK is the process environment itself. Export the required variables in the shell you run the dev server (and later the production server) from:
export TIMONIER_PLATFORM_URL="https://auth.example.com"
export TIMONIER_PROJECT_SLUG="my-project"
export TIMONIER_APP_SLUG="my-app"
export TIMONIER_SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))")
export TIMONIER_APP_ORIGIN="http://localhost:3000"
export TIMONIER_POST_SIGN_IN_PATH="/protected"
Replace the first three quoted values with the ones from Part 1, step 4. TIMONIER_APP_ORIGIN is set to http://localhost:3000 so it matches the redirect URI registered in Part 1, step 3 — leaving it empty disables the clickable magic-link callback and leaves only the code-entry sign-in flow. TIMONIER_POST_SIGN_IN_PATH is set to /protected so sign-in lands you on the guarded route built in step 10; it defaults to / if left unset.
8. Mount the broker
Create src/routes/api.timonier.$.ts:
import from "@tanstack/react-router";
import from "@timonier/tanstack/server";
export const Route = createFileRoute("/api/timonier/$")({
createAuthRouteHandlers
});
The
/api/timonier/prefix is reserved. Do not add your own routes beneath it. A more specific route under that prefix wins over this splat route, and the broker never sees the request. There is no error, no warning and no startup failure — the auth call simply stops reaching the broker, presenting as an authentication bug with no signal pointing at its cause. The SDK cannot detect this, by construction: a shadowed request never reaches the broker, so there is no code path in which it could observe its own absence.
9. Wrap the root route
Replace src/routes/__root.tsx with:
import from "@tanstack/react-router";
import from "@timonier/tanstack";
export const Route = createRootRoute({
component =>
html lang="en"
head
titletitle
HeadContent
head
body
AuthProvider
Outlet
AuthProvider
Scripts
body
html
});
<HeadContent /> and <Scripts /> are not optional decoration: <Scripts /> is what ships the client bundle, and without it nothing hydrates and <SignIn> degrades to a plain form. <HeadContent /> renders route-managed head tags; Start expects it in the document head regardless.
10. Add sign-in and sign-up pages, and a guarded route
src/routes/sign-in.tsx:
import from "@tanstack/react-router";
import from "@timonier/tanstack";
export const Route = createFileRoute("/sign-in")({
component =>SignIn
});
src/routes/sign-up.tsx:
import from "@tanstack/react-router";
import from "@timonier/tanstack";
export const Route = createFileRoute("/sign-up")({
component =>SignUp
});
<SignIn> links to /sign-up for visitors without an account — without this route that link 404s.
A route can require sign-in with createAuthGuard, registered on that route's own server.middleware:
import from "@tanstack/react-router";
import from "@timonier/tanstack/server";
import from "@timonier/tanstack";
const authGuard = createAuthGuard "/sign-in" });
export const Route = createFileRoute("/protected")({
});
function ProtectedPage
const user = useUser
return
main
h1h1
pp
main
createAuthGuard can also be registered app-wide, through createStart's requestMiddleware array, so it runs ahead of every route instead of one at a time — this guide only exercises the per-route form above.
11. Run it
npm run dev
Visit http://localhost:3000/sign-up, enter your email, then the 6-digit code from your inbox — sign-in never mails a code to an address without an existing account, so a first-time visitor starts at sign-up, not sign-in. You're redirected to /protected, where you should see Signed in as your email. That's a working integration. On a later visit, /sign-in is the route back in for that same account.
Protecting page routes under SPA mode
createAuthGuard runs exactly when the Start server runs. Under SPA mode, a statically masked page route is served from a static shell without ever reaching the server, and a client-side navigation between SPA routes never touches the server either — so neither path gets a server-side guard, and no server-side redirect fires for them.
What those paths get instead is the client surface — <SignedIn>, <SignedOut> and useUser. Here's one route that uses all three to render the protected content, the unauthenticated prompt, and the loading state explicitly:
import from "@tanstack/react-router";
import from "@timonier/tanstack";
export const Route = createFileRoute("/dashboard")({
});
function DashboardPage
const isLoaded = useUser
// Checked explicitly, off useUser, before either branch below: `<SignedIn>`
// and `<SignedOut>` render their `fallback` prop while status is "loading"
// (it defaults to null), and letting that fall through would read as
// "signed out" rather than "still finding out". Gating here — instead of
// passing a loading `fallback` to both — also avoids rendering it twice.
if!
returnpp
return
main
SignedIn
/* This only decides what renders. It authorizes nothing — the
data below comes from an API that checks the caller's session
itself, on every request. */
pp
SignedIn
SignedOut
p
Link to="/sign-in"Link
p
SignedOut
main
This is UX, not a security boundary — a determined visitor can drive client state past it. Losing the server-side redirect on a masked page route is not itself an authorization bypass, provided two preconditions hold:
- no sensitive data is present in the public SPA shell or the client bundle;
- every sensitive API independently enforces authorization on each request, rather than relying on the page having been guarded before it was called.
A router beforeLoad check is not a substitute for either precondition — it runs in the same UX layer as the client components above, not the authorization layer.
Worth noting even where the server guard does run: createAuthGuard checks only that the session cookie is present and decryptable — it never inspects the access token's expiry, so a session revoked on the platform keeps passing the guard until the sealed session itself expires (30 days: both the JWE's exp and the cookie's Max-Age). Revocation surfaces before that only when some later operation actually needs a live access token and its refresh comes back dead — typically a /me call after the token has expired, or after its userinfo lookup gets a 401. That staleness window is exactly why authorization belongs at the API, not at the guard.
API reference
This guide covers mounting the broker, wrapping the root route, sign-in and sign-up, and the route guard. For the full list of exports @timonier/tanstack provides, read the README that ships inside the installed package: node_modules/@timonier/tanstack/README.md.