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 1http://localhost:3000/api/timonier/callback for local development. This is where @timonier/tanstack receives 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, writing http:// there. For any other hostname it always writes https:// — if you're self-hosting on a real hostname and your data plane serves plain HTTP, change the scheme to http:// 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/" >> .npmrc

6. Install

npm install @timonier/tanstack

7. 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 { createFileRoute } from "@tanstack/react-router";
import { createAuthRouteHandlers } from "@timonier/tanstack/server";

export const Route = createFileRoute("/api/timonier/$")({
  server: { handlers: 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 { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import { AuthProvider } from "@timonier/tanstack";

export const Route = createRootRoute({
  component: () => (
    <html lang="en">
      <head>
        <title>My App</title>
        <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 { createFileRoute } from "@tanstack/react-router";
import { SignIn } from "@timonier/tanstack";

export const Route = createFileRoute("/sign-in")({
  component: () => <SignIn />,
});

src/routes/sign-up.tsx:

import { createFileRoute } from "@tanstack/react-router";
import { SignUp } 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 { createFileRoute } from "@tanstack/react-router";
import { createAuthGuard } from "@timonier/tanstack/server";
import { useUser } from "@timonier/tanstack";

const authGuard = createAuthGuard({ signInPath: "/sign-in" });

export const Route = createFileRoute("/protected")({
  server: { middleware: [authGuard] },
  component: ProtectedPage,
});

function ProtectedPage() {
  const { user } = useUser();
  return (
    <main>
      <h1>Protected</h1>
      <p>Signed in as {user?.email}</p>
    </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 { createFileRoute, Link } from "@tanstack/react-router";
import { SignedIn, SignedOut, useUser } from "@timonier/tanstack";

export const Route = createFileRoute("/dashboard")({
  component: DashboardPage,
});

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 (!isLoaded) {
    return <p>Loading…</p>;
  }

  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. */}
        <p>Your dashboard content.</p>
      </SignedIn>
      <SignedOut>
        <p>
          <Link to="/sign-in">Sign in</Link> to view your dashboard.
        </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:

  1. no sensitive data is present in the public SPA shell or the client bundle;
  2. 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.