TanStack Start
This guide walks through setting up authentication in a TanStack Start application using ideal-auth. By the end, you will have working login, registration, logout, route protection via beforeLoad, and access to the current user in your routes.
Installation
Section titled “Installation”-
Install ideal-auth
Terminal window bun add ideal-auth -
Set the session secret
Add a secret to your
.envfile. It must be at least 32 characters..env IDEAL_AUTH_SECRET="at-least-32-characters-long-secret-here"Generate a strong secret:
Terminal window bunx ideal-auth secret
Cookie bridge
Section titled “Cookie bridge”TanStack Start uses Vinxi under the hood. Use the cookie utilities from vinxi/http to build the bridge.
import { getCookie, setCookie, deleteCookie } from 'vinxi/http';import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(): CookieBridge { return { get(name: string) { return getCookie(name); }, set(name, value, options) { setCookie(name, value, options); }, delete(name) { deleteCookie(name, { path: '/' }); }, };}Auth setup
Section titled “Auth setup”Create a single auth() factory. Each server function calls auth() to get a fresh AuthInstance bound to the current request’s cookies.
import { createAuth, createHash } from 'ideal-auth';import { createCookieBridge } from './cookies';import { db } from './db'; // your database client
type User = { id: string; email: string; name: string; password: string;};
export const hash = createHash({ rounds: 12 });
const authFactory = createAuth<User>({ secret: process.env.IDEAL_AUTH_SECRET!, cookie: createCookieBridge(), hash,
async resolveUser(id) { return db.user.findUnique({ where: { id } }); },
async resolveUserByCredentials(credentials) { return db.user.findUnique({ where: { email: credentials.email }, }); },});
export function auth() { return authFactory();}Login server function
Section titled “Login server function”import { createServerFn } from '@tanstack/start';import { auth, hash } from './auth';import { db } from './db';
export const loginFn = createServerFn({ method: 'POST' }) .validator((data: { email: string; password: string; remember?: boolean }) => data) .handler(async ({ data }) => { if (!data.email || !data.password) { throw new Error('Email and password are required.'); }
const session = auth(); const success = await session.attempt( { email: data.email, password: data.password }, { remember: data.remember ?? false }, );
if (!success) { throw new Error('Invalid email or password.'); }
return { success: true }; });Login route
Section titled “Login route”import { createFileRoute, useNavigate } from '@tanstack/react-router';import { useServerFn } from '@tanstack/start';import { useState } from 'react';import { loginFn } from '../lib/auth.actions';
export const Route = createFileRoute('/login')({ component: LoginPage,});
function LoginPage() { const navigate = useNavigate(); const login = useServerFn(loginFn); const [error, setError] = useState(''); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setError(''); setLoading(true);
const formData = new FormData(e.currentTarget); const email = formData.get('email') as string; const password = formData.get('password') as string; const remember = formData.get('remember') === 'on';
try { await login({ data: { email, password, remember } }); navigate({ to: '/dashboard' }); } catch (err: any) { setError(err.message ?? 'Login failed.'); } finally { setLoading(false); } }
return ( <div> <h1>Sign in</h1> {error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" required />
<label> <input name="remember" type="checkbox" /> Remember me </label>
<button type="submit" disabled={loading}> {loading ? 'Signing in...' : 'Sign in'} </button> </form> </div> );}Registration server function
Section titled “Registration server function”export const registerFn = createServerFn({ method: 'POST' }) .validator( (data: { email: string; name: string; password: string; passwordConfirmation: string; }) => data, ) .handler(async ({ data }) => { if (!data.email || !data.name || !data.password) { throw new Error('All fields are required.'); }
if (data.password.length < 8) { throw new Error('Password must be at least 8 characters.'); }
if (data.password !== data.passwordConfirmation) { throw new Error('Passwords do not match.'); }
const existing = await db.user.findUnique({ where: { email: data.email } }); if (existing) { throw new Error('An account with this email already exists.'); }
const user = await db.user.create({ data: { email: data.email, name: data.name, password: await hash.make(data.password), }, });
// Log the user in immediately after registration const session = auth(); await session.login(user);
return { success: true }; });Logout server function
Section titled “Logout server function”export const logoutFn = createServerFn({ method: 'POST' }) .handler(async () => { const session = auth(); await session.logout(); return { success: true }; });Logout button
Section titled “Logout button”import { useNavigate } from '@tanstack/react-router';import { useServerFn } from '@tanstack/start';import { logoutFn } from '../lib/auth.actions';
export function LogoutButton() { const navigate = useNavigate(); const logout = useServerFn(logoutFn);
async function handleLogout() { await logout({}); navigate({ to: '/login' }); }
return <button onClick={handleLogout}>Sign out</button>;}Current user server function
Section titled “Current user server function”Create a server function to fetch the current user. This is used by route loaders and the beforeLoad guard.
export const getCurrentUserFn = createServerFn({ method: 'GET' }) .handler(async () => { const session = auth(); const user = await session.user();
if (!user) { return { user: null }; }
return { user: { id: user.id, email: user.email, name: user.name, }, }; });Route protection (beforeLoad)
Section titled “Route protection (beforeLoad)”Use beforeLoad to guard protected routes. This runs before the route’s loader, preventing unauthorized data fetching.
import { createFileRoute, redirect } from '@tanstack/react-router';import { getCurrentUserFn } from '../lib/auth.actions';import { LogoutButton } from '../components/LogoutButton';
export const Route = createFileRoute('/dashboard')({ beforeLoad: async () => { const { user } = await getCurrentUserFn();
if (!user) { throw redirect({ to: '/login', search: { callbackUrl: '/dashboard' }, }); }
return { user }; }, component: DashboardPage,});
function DashboardPage() { const { user } = Route.useRouteContext();
return ( <div> <h1>Welcome, {user.name}</h1> <p>Email: {user.email}</p> <LogoutButton /> </div> );}Reusable auth guard
Section titled “Reusable auth guard”To avoid repeating the beforeLoad logic, create a helper:
import { redirect } from '@tanstack/react-router';import { getCurrentUserFn } from './auth.actions';
export async function requireAuth(currentPath: string) { const { user } = await getCurrentUserFn();
if (!user) { throw redirect({ to: '/login', search: { callbackUrl: currentPath }, }); }
return { user };}
export async function requireGuest() { const { user } = await getCurrentUserFn();
if (user) { throw redirect({ to: '/dashboard' }); }}Use it in any route:
import { createFileRoute } from '@tanstack/react-router';import { requireAuth } from '../lib/auth-guard';
export const Route = createFileRoute('/settings')({ beforeLoad: () => requireAuth('/settings'), component: SettingsPage,});
function SettingsPage() { const { user } = Route.useRouteContext(); return <h1>Settings for {user.name}</h1>;}Getting the current user
Section titled “Getting the current user”In a route loader
Section titled “In a route loader”import { createFileRoute } from '@tanstack/react-router';import { getCurrentUserFn } from '../lib/auth.actions';import { requireAuth } from '../lib/auth-guard';
export const Route = createFileRoute('/profile')({ beforeLoad: () => requireAuth('/profile'), loader: async () => { const { user } = await getCurrentUserFn(); return { user }; }, component: ProfilePage,});
function ProfilePage() { const { user } = Route.useLoaderData();
return ( <div> <h1>{user?.name}</h1> <p>{user?.email}</p> </div> );}In a layout route
Section titled “In a layout route”import { createFileRoute, Outlet } from '@tanstack/react-router';import { requireAuth } from '../lib/auth-guard';
export const Route = createFileRoute('/_authed')({ beforeLoad: () => requireAuth('/_authed'), component: AuthedLayout,});
function AuthedLayout() { const { user } = Route.useRouteContext();
return ( <div> <nav> <span>Signed in as {user.email}</span> </nav> <main> <Outlet /> </main> </div> );}Then nest protected routes under _authed:
app/routes/ _authed.tsx (layout with auth guard) _authed.dashboard.tsx _authed.settings.tsx _authed.profile.tsx login.tsx register.tsxCSRF protection
Section titled “CSRF protection”TanStack Start does not include built-in CSRF protection. You need to validate the Origin header manually in your server functions or server middleware.
Option 1: Middleware (Vinxi)
Section titled “Option 1: Middleware (Vinxi)”import { createMiddleware } from '@tanstack/start';import { getRequestHeader } from 'vinxi/http';
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
export default createMiddleware({ id: 'csrf', handler: async ({ request, next }) => { if (SAFE_METHODS.includes(request.method)) { return next(); }
const origin = getRequestHeader('origin'); const host = getRequestHeader('host');
if (!origin || !host) { throw new Error('Forbidden: missing origin header.'); }
try { const originHost = new URL(origin).host; if (originHost !== host) { throw new Error('Forbidden: origin mismatch.'); } } catch { throw new Error('Forbidden: invalid origin.'); }
return next(); },});Option 2: Per-function validation
Section titled “Option 2: Per-function validation”import { getRequestHeader } from 'vinxi/http';
export function validateOrigin() { const origin = getRequestHeader('origin'); const host = getRequestHeader('host');
if (!origin || new URL(origin).host !== host) { throw new Error('CSRF validation failed.'); }}Call validateOrigin() at the top of any state-changing server function:
export const loginFn = createServerFn({ method: 'POST' }) .handler(async ({ data }) => { validateOrigin(); // ... rest of login logic });Security notes
Section titled “Security notes”- Session secret: Store
IDEAL_AUTH_SECRETin environment variables. Never hard-code it or commit it to version control. - HTTPS: Set
NODE_ENV=productionin production so session cookies are automatically markedSecure. - Cookie scope: The default
SameSite=LaxandHttpOnly=truesettings protect against CSRF and XSS cookie theft. - Server function context: All
vinxi/httpcookie operations must run inside a server function. Calling them at module level or in client code will throw. - Route context types: Use
Route.useRouteContext()to access data set inbeforeLoad. TypeScript will infer the types automatically from your return value. - Layout routes: Use TanStack Router’s pathless layout routes (prefixed with
_) to apply auth guards to groups of routes without repeating thebeforeLoadlogic.