File: README.md
| ## TanStack Router
A modern router designed for type safety, data‑driven navigation, and seamless developer experience.
- End‑to-end type safety (routes, params, loaders)
- Schema‑driven search params with validation
- Built‑in caching, prefetching & invalidation
- Nested layouts, transitions & error boundaries
[Read the Router Docs →](https://tanstack.com/router) | ## TanStack Start
A full‑stack framework built on Router, designed for server rendering, streaming, and production‑ready deployments.
- Full‑document SSR & streaming
- Server functions & end‑to‑end type safety
- Deployment‑ready bundling & builds
- All the power of TanStack Router, plus full‑stack features
[Read the Start Docs →](https://tanstack.com/start) |
| --- | --- |
Get Involved
- We welcome issues and pull requests!
- Participate in GitHub discussions
- Chat with the community on Discord
- See CONTRIBUTING.md for setup instructions
Partners
Explore the TanStack Ecosystem
- TanStack Config – Tooling for JS/TS packages
- TanStack DB – Reactive sync client store
- TanStack DevTools – Unified devtools panel
- TanStack Form – Type‑safe form state
- TanStack Pacer – Debouncing, throttling, batching
- TanStack Query – Async state & caching
- TanStack Ranger – Range & slider primitives
- TanStack Store – Reactive data store
- TanStack Table – Headless datagrids
- TanStack Virtual – Virtualized rendering
… and more at TanStack.com »
File: benchmarks/ssr/README.md
SSR Benchmarks
Cross-framework SSR request-loop benchmarks for:
@tanstack/react-start@tanstack/solid-start@tanstack/vue-start
Each benchmark builds a Start app with file-based routes and runs Vitest benches against the built server handler.
Layout
react/- React Start baseline benchmark + Vitest configsolid/- Solid Start baseline benchmark + Vitest configvue/- Vue Start baseline benchmark + Vitest configvitest.react.config.ts,vitest.solid.config.ts,vitest.vue.config.ts- per-framework aggregate configs that run the baseline first, then scenario projectsscenarios/<scenario>/<framework>/- isolated scenario apps
Scenario app layout:
scenarios/<scenario>/<framework>/
vite.config.ts
speed.bench.ts
tsconfig.json
src/
router.tsx
routes/
routeTree.gen.tsEach scenario uses one app per framework instead of sharing routes in the baseline app. This keeps route-tree size, middleware, Start options, and generated route trees isolated so one scenario cannot shift another scenario's numbers. The existing baseline apps and bench names stay stable for CodSpeed continuity.
Scenario Responsibilities
Each scenario isolates one Start server-side responsibility so benchmark changes can be attributed to a specific feature area.
| Scenario | Start server-side responsibility |
|---|---|
react/, solid/, vue/ baseline apps |
Document SSR for nested file routes, route matching, search parsing, and full-page HTML response generation. |
assets |
Per-request asset pipeline work: CSS inlining, CDN asset URL transforms with uncached manifest resolution, and response Link header collection for early hints. |
before-load |
Nested beforeLoad execution: sequential per-match context building, context merging across matches, and context consumption by loaders during document SSR. |
control-flow |
Loader-thrown redirect, notFound, plain errors, unmatched routes, and route headers() emission, including HTTP status selection, redirect location headers, route error boundaries, and not-found HTML rendering. |
global-middleware |
Global createStart middleware registration: request middleware wrapping document SSR, server-function, and server-route requests, plus global function middleware on server functions. |
head |
Nested route head evaluation, title/meta/link serialization, and head-entry deduplication during SSR. |
loaders |
Nested route loader execution, loader deps from search params, router context reads, and dehydrated loader payload generation. |
rewrites |
Composed location rewrites from router basepath plus locale input/output rewrites; unprefixed URLs redirect to /app/* when the basepath output rewrite canonicalizes them. |
serialization |
Dehydration and RPC serialization of rich non-plain-JSON types: seroval native types in loader payloads, custom serialization adapters, and shallow-error serialization. |
selective-ssr |
Route-level ssr modes: rendered server HTML with ssr: true, dehydrated data without HTML with ssr: 'data-only', and client-only omission with ssr: false. |
server-fn-transport |
Non-JSON server-function transport: multipart/FormData request decoding, raw Response returns, and RawStream streamed responses through the binary frame protocol. |
server-fns |
createServerFn GET/POST request handling, thrown redirect/notFound results, sendContext serialization, SSR-time direct calls, middleware context, input validation, and URL discovery. |
server-routes |
File route server.handlers dispatch for parameterized JSON API routes without document HTML rendering. |
server-routes-middleware |
Server route request middleware chains, middleware-provided context merging, and handler access to accumulated context. |
streaming |
Deferred loader data, Await/Suspense fallback output, larger streamed HTML body scanning, streamed HTML ordering, and later dehydration payload emission. |
Run
Run all benchmarks through Nx so dependency builds are part of the graph:
pnpm nx run @benchmarks/ssr:test:perf --outputStyle=stream --skipRemoteCacheRun framework-specific benchmarks:
pnpm nx run @benchmarks/ssr:test:perf:react --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/ssr:test:perf:solid --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/ssr:test:perf:vue --outputStyle=stream --skipRemoteCacheBuild framework-specific benchmark apps:
pnpm nx run @benchmarks/ssr:build:react --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/ssr:build:solid --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/ssr:build:vue --outputStyle=stream --skipRemoteCacheTypecheck benchmark sources:
pnpm nx run @benchmarks/ssr:test:types --outputStyle=stream --skipRemoteCacheRun one scenario app manually through Nx:
pnpm nx run @benchmarks/ssr-<scenario>-<framework>:build:ssr --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/ssr-<scenario>-<framework>:test:types:ssr --outputStyle=stream --skipRemoteCacheUse react, solid, or vue for <framework>. The baseline projects use @benchmarks/ssr-<framework> without a scenario segment.
Request Conventions
- Document GET loops use
accept: text/html, matching the baseline request shape. - Server-function loops must include
sec-fetch-site: same-originso the default CSRF middleware accepts the request. - Loops that expect non-200 responses pass a custom
validateResponsetorunRequestLoop. - Bench loops must build deterministic requests from the seeded random helper and consume response bodies through
runRequestLooporrunSsrRequestLoop.
File: benchmarks/client-nav/README.md
Client Navigation Benchmarks
Cross-framework client-side CPU benchmarks for:
@tanstack/react-router@tanstack/solid-router@tanstack/vue-router
The benchmarks run in jsdom against production builds of real apps, and are
tracked in CI by CodSpeed (simulation mode).
Scope: these benchmarks cover the standalone client router only. The
client side of TanStack Start (hydration of a server-rendered document,
streamed payload consumption, server-function calls from the client, ...) is
not covered here; the server side of Start is covered bybenchmarks/ssr.
Layout
react/,solid/,vue/- baseline benchmark (mixed navigation loop) + Vitest configvitest.react.config.ts,vitest.solid.config.ts,vitest.vue.config.ts- per-framework aggregate configs that run the baseline first, then scenario projectsscenarios/harness.ts- shared scenario runner (mount, link-click steps,onRenderedsynchronization)scenarios/<scenario>/shared.ts- framework-agnostic scenario definition (workload data, step sequence, assertions, bench options)scenarios/<scenario>/<framework>/- isolated scenario apps
Scenario app layout:
scenarios/<scenario>/<framework>/
vite.config.ts
speed.bench.ts
speed.flame.ts
setup.ts
project.json
tsconfig.json
src/
main.tsx
routeTree.gen.ts
routes/Scenario apps use file-based routing (@tanstack/router-plugin) with a
generated routeTree.gen.ts, like a regular user app. Each scenario uses one
app per framework instead of sharing routes in the baseline app. This keeps
route-tree size and router options isolated so one scenario cannot shift
another scenario's numbers. The existing baseline apps and bench names stay
stable for CodSpeed continuity.
Scenario Responsibilities
Each scenario isolates one client-side responsibility so benchmark changes can
be attributed to a specific feature area.
| Scenario | Client-side responsibility |
|---|---|
react/, solid/, vue/ baseline apps |
Mixed navigation loop: path params, search params, route context, and useParams/useSearch/useLoaderData selector subscriptions. |
async-pipeline |
The router's async pipeline via counted 0ms timer hops: async loaders (transition-held navigation), async beforeLoad context, and parallel nested async loaders. Component-level Await/Suspense is excluded: React 19 throttles Suspense reveals by ~300ms wall-clock, which is inherently non-deterministic to benchmark. |
control-flow |
Loader-thrown redirect (including a 2-hop chain), notFound() with notFoundComponent, loader errors with errorComponent, and boundary reset on recovery navigation. |
head |
HeadContent per-navigation work: nested route head() evaluation, title/meta/link dedupe across matches, and head tag DOM updates during navigation. |
history |
History push/replace/back/forward traversal, location masking, registered-but-never-blocking useBlocker, and useCanGoBack/useLocation subscriptions. |
links |
Per-navigation cost of ~200 mounted <Link>s: link prop building, active-state recompute across activeOptions variants, activeProps swaps, and useMatchRoute probes (the MatchRoute component is avoided: vue-router's implementation leaks one subscription per render). |
loaders |
Client loader dispatch: always-stale re-runs (staleTime: 0), cached revisits (re-run once per lap by the invalidate step), loaderDeps-keyed caching, router.invalidate(), and useLoaderData selectors. |
mount |
Cold start: createRouter (route-tree processing) + first render + initial router.load() + unmount, with a fresh router per mount. The only scenario measuring router creation. |
nested-params |
Deep nesting (8 dynamic levels): per-level params.parse/stringify, beforeLoad context accumulation across matches, and per-level useParams/useRouteContext subscriptions. Param values include characters requiring percent-encoding (as do route-tree-scale's), so segment encode/decode paths run on every navigation. |
preload |
Intent preloading from hover events, programmatic router.preloadRoute, deterministic preload cache behavior (defaultPreloadStaleTime: 0), and commit-time cache maintenance. |
rewrites |
Composed client-side location rewrites: router basepath plus a locale input/output rewrite pair, running on every href build and location parse (the client analog of the SSR rewrites scenario). |
route-tree-scale |
Route matching and link-target resolution on a wide (~40 route) tree mixing static, dynamic, prefixed-param, splat, pathless-layout, and route-group paths, with autoCodeSplitting enabled so navigations also resolve lazy route chunks. |
search-params |
validateSearch execution, search middlewares (retainSearchParams/stripSearchParams), functional search updaters, structural sharing, and useSearch selector subscriptions. |
Conventions
- Apps are built with
NODE_ENV=production(minify: false) intodist/app.js; benches import the built bundle, so production package builds and production JSX output are measured, not dev transforms. - Scenarios behave like a real user app: navigation happens through
<Link>clicks dispatched on real anchor elements (unless a scenario specifically measures the imperative API), the router uses the default browser history, andscrollRestorationis enabled. - Each benchmark iteration advances a fixed, circular sequence of steps; every step awaits the router's
onRenderedevent, so render work is included and steps cannot overlap. No two consecutive steps may target the same location, and the sequence ends back on the initial route. - Setup runs one warm-up lap through the sequence and asserts each step's observable output (e.g.
document.title), so a scenario that silently stops doing its work fails instead of reporting a fast time. - Determinism: no wall-clock timers (async work is resolved promises or counted
setTimeout(0)hops),staleTime/gcTimeonly ever0or effectively-infinite, noMath.random/Date.nowin the measured loop. Scroll restoration usesgetScrollRestorationKey: (location) => location.pathnameso its cache stays bounded (the default random per-entry key grows one cache entry per push navigation). Themountscenario opts out ofscrollRestorationentirely and destroys its history on unmount — both register page-lifetime globals that would leak across its mount/unmount loop. - Push-only step laps grow jsdom's session-history entry list over a run (jsdom never truncates entries behind the current index). Per-push cost stays O(1) so timings are unaffected; only the
historyscenario needs — and has — a lap that is depth-stationary. - Calibration: pick per-iteration step counts so a bench's
vitest benchrun stays roughly between 8 and 30 seconds (long enough to average out, short enough for CI).
Run
Run all benchmarks through Nx so dependency builds are part of the graph:
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf --outputStyle=stream --skipRemoteCacheRun framework-specific benchmarks (baseline + all scenarios):
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:react --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:solid --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:perf:vue --outputStyle=stream --skipRemoteCacheRun a single scenario app manually (after building it through Nx):
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-<scenario>-<framework>:build:client --outputStyle=stream --skipRemoteCache
cd benchmarks/client-nav && NODE_ENV=production vitest bench --config ./scenarios/<scenario>/<framework>/vite.config.tsRun framework-specific flame benchmarks (10 second loop, profiled with @platformatic/flame, forced to NODE_ENV=production):
# baseline
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:react --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:solid --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:flame:vue --outputStyle=stream --skipRemoteCache
# scenarios
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav-<scenario>-<framework>:test:flame --outputStyle=stream --skipRemoteCacheTypecheck benchmark sources (baseline + scenarios):
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/client-nav:test:types --outputStyle=stream --skipRemoteCacheFile: benchmarks/bundle-size/README.md
Bundle Size Benchmarks
This workspace contains deterministic bundle-size fixtures for:
@tanstack/react-router@tanstack/solid-router@tanstack/vue-router@tanstack/react-start@tanstack/solid-start
Each package has minimal and full scenarios:
minimal: Small route app with__root+ index route that rendershello worldfull: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface- Start
fullscenarios also exercisecreateServerFn,createMiddleware, anduseServerFn - Start
deferred-hydrationscenarios match the minimal route shape and wrap the index route content inHydrate
Design Notes
- Scenarios use file-based routing as the default app style.
- Router scenarios use
@tanstack/router-plugin/vitewithautoCodeSplitting: true. - Start Vite scenarios use
@tanstack/<framework>-start/plugin/vitewith router code-splitting enabled. - React Start also includes Rsbuild scenarios using
@tanstack/react-start/plugin/rsbuild. - Full-surface coverage is manually maintained (no strict export-coverage gate).
- Primary metrics measure all emitted client JS chunks and are reported as raw/gzip/brotli bytes.
- Initial-load JS graph metrics are also recorded as
initialRawBytes,initialGzipBytes, andinitialBrotliBytesfor context. - Gzip for all emitted client JS is the primary tracking signal for PR deltas and historical charting.
Local Run
pnpm nx run @benchmarks/bundle-size:buildRun one or more scenarios during local optimization:
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal,react-router.fullFiltered runs build only the package projects needed by selected scenarios. Full runs build all package projects needed by all scenarios. If the required packages are already built and unchanged, skip that step:
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal --skip-package-buildsThis writes:
benchmarks/bundle-size/results/current.jsonbenchmarks/bundle-size/results/benchmark-action.json
current.json includes run status, selected package build projects, per-scenario totals, per-file sizes, and the emitted JS files used for measurement. Dist paths use scenarioDir/outDir, e.g. react-router.minimal maps to benchmarks/bundle-size/dist/react-router-minimal/.
Local Query Tools
pnpm benchmark:bundle-size:query --id react-router.minimal
pnpm benchmark:bundle-size:diff --baseline /tmp/base-current.json --id react-router.minimal
pnpm benchmark:bundle-size:history --id react-router.minimal --top-deltas 20For source attribution, run an analysis build. This uses hidden source maps and writes source estimates into current.json; those estimates are for investigation only, not tracking.
pnpm nx run @benchmarks/bundle-size:build -- --scenario react-router.minimal --analysis
pnpm benchmark:bundle-size:analyze --id react-router.minimal --top-sources 30CI Reporting
- PR workflow generates a sticky comment with:
- current gzip values
- baseline delta
- inline sparkline trend
- Pushes to
mainpublish historical chart data to GitHub Pages viabenchmark-action/github-action-benchmark.
Manual Update Policy
When router/start public hooks/components evolve, update the corresponding *-full/src/routes/__root.tsx harness to keep full scenarios representative.
Backfill Readiness
The measurement script supports optional interfaces for historical backfilling:
--sha--measured-at--append-history--scenario--analysis--sourcemap--skip-package-builds
These are intended for one-off scripts that replay historical commits and append results to the same history dataset shape used for chart generation.
If --append-history points at a data.js file, output is written as window.BENCHMARK_DATA = ... for direct GitHub Pages compatibility.
File: docs/start/eslint/eslint-plugin-start.md
id: eslint-plugin-start title: ESLint Plugin Start
TanStack Start comes with its own ESLint plugin. This plugin is used to enforce best practices and help you avoid common mistakes when working with Server Components and server/client boundaries.
Installation
The plugin is a separate package that you need to install:
npm install -D @tanstack/eslint-plugin-startor
pnpm add -D @tanstack/eslint-plugin-startor
yarn add -D @tanstack/eslint-plugin-startor
bun add -D @tanstack/eslint-plugin-startFlat Config (`eslint.config.js`)
The release of ESLint 9.0 introduced a new way to configure ESLint using a flat config format. The TanStack Start ESLint Plugin supports this format and provides a recommended config you can use to enable all recommended rules.
Recommended Flat Config setup
To enable all recommended rules:
// eslint.config.js
import pluginStart from '@tanstack/eslint-plugin-start'
export default [
pluginStart.configs['flat/recommended'],
// Any other config...
]Custom Flat Config setup
Alternatively, load the plugin and configure only the rules you want:
// eslint.config.js
import pluginStart from '@tanstack/eslint-plugin-start'
export default [
{
plugins: {
'@tanstack/start': pluginStart,
},
rules: {
'@tanstack/start/no-client-code-in-server-component': 'error',
'@tanstack/start/no-async-client-component': 'error',
},
},
// Any other config...
]Legacy Config (`.eslintrc`)
Prior to ESLint 9.0, the most common way to configure ESLint was using a .eslintrc file. The TanStack Start ESLint plugin still supports this configuration method.
Recommended Legacy Config setup
To enable all recommended rules, add plugin:@tanstack/eslint-plugin-start/recommended in extends:
{
"extends": ["plugin:@tanstack/eslint-plugin-start/recommended"]
}Custom Legacy Config setup
Alternatively, add @tanstack/eslint-plugin-start to plugins and configure the rules you want:
{
"plugins": ["@tanstack/eslint-plugin-start"],
"rules": {
"@tanstack/start/no-client-code-in-server-component": "error",
"@tanstack/start/no-async-client-component": "error"
}
}Rules
The following rules are available in the TanStack Start ESLint plugin:
File: docs/start/eslint/no-async-client-component.md
id: no-async-client-component title: Disallow async components in client context
Async React components are only valid within React Server Components.
In TanStack Start, that means async components must only be used in server-component context, for example:
- rendered by
renderServerComponent(<AsyncComp />) - rendered inside a
createCompositeComponent((props) => <AsyncComp />)callback
This rule helps you catch async components that have become "client" (directly or transitively), which would otherwise crash at runtime.
Rule Details
Examples of incorrect code for this rule:
/* eslint "@tanstack/start/no-async-client-component": "error" */
import { createFileRoute } from '@tanstack/react-router'
export async function Page() {
return
}
export const Route = createFileRoute(undefined)({
component: Page,
})Examples of correct code for this rule:
/* eslint "@tanstack/start/no-async-client-component": "error" */
import { createFileRoute } from '@tanstack/react-router'
export function Page() {
return
}
export const Route = createFileRoute(undefined)({
component: Page,
})/* eslint "@tanstack/start/no-async-client-component": "error" */
import { createCompositeComponent } from '@tanstack/react-start/rsc'
export const ServerPage = createCompositeComponent(async () => {
const message = await Promise.resolve('hello')
return {message}
})Reporting location
This rule can report in two ways:
- Usage-site: if a component becomes client due to being referenced by route options, the error should be reported at the route option usage.
- Definition-site: if a component is client due to
'use client'or being rendered by a client component, the error is reported at the component definition.
Attributes
- ✅ Recommended
File: docs/start/eslint/no-client-code-in-server-component.md
id: no-client-code-in-server-component title: Disallow client-only code in server components
TanStack Start defines React Server Component boundaries via RSC helpers like renderServerComponent() and createCompositeComponent().
renderServerComponent(<Element />)is a server boundary around the element.createCompositeComponent((props) => <Element />)is a server boundary around the callback.
Inside these boundaries (and anything rendered from them, unless it crosses a 'use client' boundary) you must not use client-only code.
This rule detects common client-only patterns in server components:
- React client hooks (eg
useState,useEffect) - browser-only globals (eg
window,document) - React event handlers (
onClick, etc.) - function props (non-serializable across the server/client boundary)
- class components
Rule Details
Examples of incorrect code for this rule:
/* eslint "@tanstack/start/no-client-code-in-server-component": "error" */
import { createCompositeComponent } from '@tanstack/react-start/rsc'
import { useState } from 'react'
export const Server = createCompositeComponent(() => {
const [count] = useState(0)
return {count}
})/* eslint "@tanstack/start/no-client-code-in-server-component": "error" */
import { createCompositeComponent } from '@tanstack/react-start/rsc'
export const Server = createCompositeComponent(() => {
return {window.location.href}
})Examples of correct code for this rule:
/* eslint "@tanstack/start/no-client-code-in-server-component": "error" */
import { renderServerComponent } from '@tanstack/react-start/rsc'
export const Server = renderServerComponent(
(async () => {
const msg = await Promise.resolve('ok')
return {msg}
})(),
)Attributes
- ✅ Recommended
File: docs/start/framework/solid/guide/authentication.md
id: authentication title: Authentication
This guide covers authentication patterns and shows how to implement your own authentication system with TanStack Start.
📋 Before You Start: Check our Authentication Overview for all available options including partner solutions and hosted services.
Authentication Approaches
You have several options for authentication in your TanStack Start application:
Hosted Solutions:
- Clerk - Complete authentication platform with UI components
- WorkOS - Enterprise-focused with SSO and compliance features
- Better Auth - Open-source TypeScript library
- Auth.js - Open-source library supporting 80+ OAuth providers
DIY Implementation Benefits:
- Full Control: Complete customization over authentication flow
- No Vendor Lock-in: Own your authentication logic and user data
- Custom Requirements: Implement specific business logic or compliance needs
- Cost Control: No per-user pricing or usage limits
Authentication involves many considerations including password security, session management, rate limiting, CSRF protection, and various attack vectors.
Core Concepts
Authentication vs Authorization
- Authentication: Who is this user? (Login/logout)
- Authorization: What can this user do? (Permissions/roles)
TanStack Start provides the tools for both through server functions, sessions, and route protection.
Protect the data/API boundary first. Any server function, server route, or other API endpoint that returns or mutates private data must authorize the request itself.
beforeLoadis useful for route UX: it keeps users out of screens they cannot use and avoids triggering work that would fail anyway. It is not the security boundary for the data. See Authentication Server Primitives for the server-side pattern.
Essential Building Blocks
1. Server Functions for Authentication
Server functions handle sensitive authentication logic securely on the server:
import { createServerFn } from '@tanstack/solid-start'
import { redirect } from '@tanstack/solid-router'
// Login server function
export const loginFn = createServerFn({ method: 'POST' })
.validator((data: { email: string; password: string }) => data)
.handler(async ({ data }) => {
// Verify credentials (replace with your auth logic)
const user = await authenticateUser(data.email, data.password)
if (!user) {
return { error: 'Invalid credentials' }
}
// Create session
const session = await useAppSession()
await session.update({
userId: user.id,
email: user.email,
})
// Redirect to protected area
throw redirect({ to: '/dashboard' })
})
// Logout server function
export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
const session = await useAppSession()
await session.clear()
throw redirect({ to: '/' })
})
// Get current user
export const getCurrentUserFn = createServerFn({ method: 'GET' }).handler(
async () => {
const session = await useAppSession()
const userId = session.get('userId')
if (!userId) {
return null
}
return await getUserById(userId)
},
)2. Session Management
TanStack Start provides secure HTTP-only cookie sessions:
// utils/session.ts
import { useSession } from '@tanstack/solid-start/server'
type SessionData = {
userId?: string
email?: string
role?: string
}
export function useAppSession() {
return useSession<SessionData>({
// Session configuration
name: 'app-session',
password: process.env.SESSION_SECRET!, // At least 32 characters
// Optional: customize cookie settings
cookie: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
httpOnly: true,
},
})
}3. Authentication Context
Share authentication state across your application:
// contexts/auth.tsx
import { createContext, useContext } from 'solid-js'
import { useServerFn } from '@tanstack/solid-start'
import { getCurrentUserFn } from '../server/auth'
type User = {
id: string
email: string
role: string
}
type AuthContextType = {
user: User | null
isLoading: boolean
refetch: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider(props) {
const { data: user, isLoading, refetch } = useServerFn(getCurrentUserFn)
return (
<AuthContext.Provider value={{ user, isLoading, refetch }}>
{props.children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}4. Route Protection
Protect routes using beforeLoad:
// routes/_authed.tsx - Layout route for protected pages
import { createFileRoute, redirect } from '@tanstack/solid-router'
import { getCurrentUserFn } from '../server/auth'
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ location }) => {
const user = await getCurrentUserFn()
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
// Pass user to child routes
return { user }
},
})// routes/_authed/dashboard.tsx - Protected route
import { createFileRoute } from '@tanstack/solid-router'
export const Route = createFileRoute('/_authed/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const context = Route.useRouteContext()
return (
<h1>Welcome, {context().user.email}!</h1>
{/* Dashboard content */}
)
}Implementation Patterns
Basic Email/Password Authentication
// server/auth.ts
import bcrypt from 'bcryptjs'
import { createServerFn } from '@tanstack/solid-start'
// User registration
export const registerFn = createServerFn({ method: 'POST' })
.validator((data: { email: string; password: string; name: string }) => data)
.handler(async ({ data }) => {
// Check if user exists
const existingUser = await getUserByEmail(data.email)
if (existingUser) {
return { error: 'User already exists' }
}
// Hash password
const hashedPassword = await bcrypt.hash(data.password, 12)
// Create user
const user = await createUser({
email: data.email,
password: hashedPassword,
name: data.name,
})
// Create session
const session = await useAppSession()
await session.update({ userId: user.id })
return { success: true, user: { id: user.id, email: user.email } }
})
async function authenticateUser(email: string, password: string) {
const user = await getUserByEmail(email)
if (!user) return null
const isValid = await bcrypt.compare(password, user.password)
return isValid ? user : null
}Role-Based Access Control (RBAC)
// utils/auth.ts
export const roles = {
USER: 'user',
ADMIN: 'admin',
MODERATOR: 'moderator',
} as const
type Role = (typeof roles)[keyof typeof roles]
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
const hierarchy = {
[roles.USER]: 0,
[roles.MODERATOR]: 1,
[roles.ADMIN]: 2,
}
return hierarchy[userRole] >= hierarchy[requiredRole]
}
// Protected route with role check
export const Route = createFileRoute('/_authed/admin/')({
beforeLoad: async ({ context }) => {
if (!hasPermission(context.user.role, roles.ADMIN)) {
throw redirect({ to: '/unauthorized' })
}
},
})Social Authentication Integration
// Example with OAuth providers
export const authProviders = {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
redirectUri: `${process.env.APP_URL}/auth/google/callback`,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
redirectUri: `${process.env.APP_URL}/auth/github/callback`,
},
}
export const initiateOAuthFn = createServerFn({ method: 'POST' })
.validator((data: { provider: 'google' | 'github' }) => data)
.handler(async ({ data }) => {
const provider = authProviders[data.provider]
const state = generateRandomState()
// Store state in session for CSRF protection
const session = await useAppSession()
await session.update({ oauthState: state })
// Generate OAuth URL
const authUrl = generateOAuthUrl(provider, state)
throw redirect({ href: authUrl })
})Password Reset Flow
// Password reset request
export const requestPasswordResetFn = createServerFn({ method: 'POST' })
.validator((data: { email: string }) => data)
.handler(async ({ data }) => {
const user = await getUserByEmail(data.email)
if (!user) {
// Don't reveal if email exists
return { success: true }
}
const token = generateSecureToken()
const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await savePasswordResetToken(user.id, token, expires)
await sendPasswordResetEmail(user.email, token)
return { success: true }
})
// Password reset confirmation
export const resetPasswordFn = createServerFn({ method: 'POST' })
.validator((data: { token: string; newPassword: string }) => data)
.handler(async ({ data }) => {
const resetToken = await getPasswordResetToken(data.token)
if (!resetToken || resetToken.expires < new Date()) {
return { error: 'Invalid or expired token' }
}
const hashedPassword = await bcrypt.hash(data.newPassword, 12)
await updateUserPassword(resetToken.userId, hashedPassword)
await deletePasswordResetToken(data.token)
return { success: true }
})Security Best Practices
1. Password Security
// Use strong hashing (bcrypt, scrypt, or argon2)
import bcrypt from 'bcryptjs'
const saltRounds = 12 // Adjust based on your security needs
const hashedPassword = await bcrypt.hash(password, saltRounds)2. Session Security
// Use secure session configuration
export function useAppSession() {
return useSession({
name: 'app-session',
password: process.env.SESSION_SECRET!, // 32+ characters
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // CSRF protection
httpOnly: true, // XSS protection
maxAge: 7 * 24 * 60 * 60, // 7 days
},
})
}3. Rate Limiting
// Simple in-memory rate limiting (use Redis in production)
const loginAttempts = new Map<string, { count: number; resetTime: number }>()
export const rateLimitLogin = (ip: string): boolean => {
const now = Date.now()
const attempts = loginAttempts.get(ip)
if (!attempts || now > attempts.resetTime) {
loginAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }) // 15 min
return true
}
if (attempts.count >= 5) {
return false // Too many attempts
}
attempts.count++
return true
}4. Input Validation
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(100),
})
export const loginFn = createServerFn({ method: 'POST' })
.validator((data) => loginSchema.parse(data))
.handler(async ({ data }) => {
// data is now validated
})Testing Authentication
Unit Testing Server Functions
// __tests__/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { loginFn } from '../server/auth'
describe('Authentication', () => {
beforeEach(async () => {
await setupTestDatabase()
})
it('should login with valid credentials', async () => {
const result = await loginFn({
data: { email: '[email protected]', password: 'password123' },
})
expect(result.error).toBeUndefined()
expect(result.user).toBeDefined()
})
it('should reject invalid credentials', async () => {
const result = await loginFn({
data: { email: '[email protected]', password: 'wrongpassword' },
})
expect(result.error).toBe('Invalid credentials')
})
})Integration Testing
// __tests__/auth-flow.test.tsx
import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library'
import { RouterProvider, createMemoryHistory } from '@tanstack/solid-router'
import { router } from '../router'
describe('Authentication Flow', () => {
it('should redirect to login when accessing protected route', async () => {
const history = createMemoryHistory()
history.push('/dashboard') // Protected route
render(<RouterProvider router={router} history={history} />)
await waitFor(() => {
expect(screen.getByText('Login')).toBeInTheDocument()
})
})
})Common Patterns
Loading States
function LoginForm() {
const [isLoading, setIsLoading] = createSignal(false)
const loginMutation = useServerFn(loginFn)
const handleSubmit = async (data: LoginData) => {
setIsLoading(true)
try {
await loginMutation.mutate(data)
} catch (error) {
// Handle error
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{isLoading() ? 'Logging in...' : 'Login'}
</form>
)
}Remember Me Functionality
export const loginFn = createServerFn({ method: 'POST' })
.validator(
(data: { email: string; password: string; rememberMe?: boolean }) => data,
)
.handler(async ({ data }) => {
const user = await authenticateUser(data.email, data.password)
if (!user) return { error: 'Invalid credentials' }
const session = await useAppSession()
await session.update(
{ userId: user.id },
{
// Extend session if remember me is checked
maxAge: data.rememberMe ? 30 * 24 * 60 * 60 : undefined, // 30 days vs session
},
)
return { success: true }
})Migration from Other Solutions
From Client-Side Auth
If you're migrating from client-side authentication (localStorage, context only):
- Move authentication logic to server functions
- Replace localStorage with server sessions
- Update route protection to use
beforeLoad - Add proper security headers and CSRF protection
From Other Frameworks
- Next.js: Replace API routes with server functions, migrate NextAuth sessions
- Remix: Convert loaders/actions to server functions, adapt session patterns
- SvelteKit: Move form actions to server functions, update route protection
Production Considerations
When choosing your authentication approach, consider these factors:
Hosted vs DIY Comparison
Hosted Solutions (Clerk, WorkOS, Better Auth):
- Pre-built security measures and regular updates
- UI components and user management features
- Compliance certifications and audit trails
- Support and documentation
- Per-user or subscription pricing
DIY Implementation:
- Complete control over implementation and data
- No ongoing subscription costs
- Custom business logic and workflows
- Responsibility for security updates and monitoring
- Need to handle edge cases and attack vectors
Security Considerations
Authentication systems need to handle various security aspects:
- Password hashing and timing attack prevention
- Session management and fixation protection
- CSRF and XSS protection
- Rate limiting and brute force prevention
- OAuth flow security
- Compliance requirements (GDPR, CCPA, etc.)
Next Steps
When implementing authentication, consider:
- Security Review: Review your implementation for security best practices
- Performance: Add caching for user lookups and session validation
- Monitoring: Add logging and monitoring for authentication events
- Compliance: Ensure compliance with relevant regulations if storing personal data
For other authentication approaches, check the Authentication Overview. For specific integration help, see the How-to Guides or explore our working examples.
File: docs/start/framework/solid/guide/authentication-overview.md
ref: docs/start/framework/react/guide/authentication-overview.md
replace:
{
'@tanstack/react-start': '@tanstack/solid-start',
'React': 'SolidJS',
'react-router': 'solid-router',
}
File: docs/start/framework/solid/guide/client-entry-point.md
id: client-entry-point title: Client Entry Point
Client Entry Point
[!NOTE]
The client entry point is optional out of the box. If not provided, TanStack Start will automatically handle the client entry point for you using the below as a default.
Getting our html to the client is only half the battle. Once there, we need to hydrate our client-side JavaScript once the route resolves to the client. We do this by hydrating the root of our application with the StartClient component:
// src/client.tsx
import { hydrate } from 'solid-js/web'
import { StartClient, hydrateStart } from '@tanstack/solid-start/client'
hydrateStart().then((router) => {
hydrate(() => <StartClient router={router} />, document)
})This enables us to kick off client-side routing once the user's initial server request has fulfilled.
Error Handling
You can wrap your client entry point with error boundaries to handle client-side errors gracefully:
// src/client.tsx
import { StartClient } from '@tanstack/solid-start/client'
import { hydrate } from 'solid-js/web'
import { ErrorBoundary } from './components/ErrorBoundary'
hydrate(
() => (
<ErrorBoundary>
<StartClient />
</ErrorBoundary>
),
document.body,
)Development vs Production
You may want different behavior in development vs production:
// src/client.tsx
import { StartClient } from '@tanstack/solid-start/client'
import { hydrate } from 'solid-js/web'
const App = (
<>
{import.meta.env.DEV && Development Mode}
<StartClient />
</>
)
hydrate(() => <App />, document.body)The client entry point gives you full control over how your application initializes on the client side while working seamlessly with TanStack Start's server-side rendering.