| name | tanstack-client-auth |
| description | Token-based authentication for TanStack Client (SPA) apps including login/logout, protected routes, auth context, token storage, and route guards. SHARED skill for both TanStack Start (client-only mode) and TanStack Router. |
TanStack Client Authentication
Overview
This skill covers implementing token-based authentication in TanStack Router (SPA) and TanStack Start (client-only mode) applications. It includes token storage, authentication context, protected routes, route guards, and integration with API clients.
Important: This skill is for client-side authentication. For server-side session-based auth in TanStack Start with SSR, use tanstack-start-auth instead.
When to Use This Skill
Use this skill when:
- Building a TanStack Router SPA with external API backend
- Using TanStack Start in client-only mode (
defaultSsr: false)
- Implementing JWT or token-based authentication
- Need to protect routes behind authentication
- Managing auth state client-side
Prerequisites
- TanStack Router or TanStack Start project set up
- API backend with authentication endpoints
- Basic understanding of JWT tokens
Architecture Overview
Auth Flow:
1. User submits credentials → POST /api/auth/login
2. Backend returns token → Store in localStorage/sessionStorage
3. Add token to API requests → Authorization: Bearer {token}
4. Check auth state → Redirect to /login if needed
5. Logout → Clear token → Redirect to /login
Components:
- AuthContext: Global auth state management
- AuthProvider: Wraps app with auth context
- useAuth: Hook to access auth state
- Protected Routes: Components that require authentication
- Route Guards: beforeLoad checks for auth
- Token Storage: localStorage/sessionStorage utilities
Step 1: Create Auth Types
Create src/lib/auth/types.ts:
export interface User {
id: string
email: string
display_name: string
}
export interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
}
export interface AuthContextValue extends AuthState {
login: (email: string, password: string) => Promise<void>
logout: () => void
register: (email: string, password: string, displayName: string) => Promise<void>
refreshUser: () => Promise<void>
}
export {
:
:
}
{
:
:
:
}
Step 2: Create Token Storage Utilities
Create src/lib/auth/storage.ts:
const TOKEN_KEY = 'auth_token'
const USER_KEY = 'auth_user'
export const tokenStorage = {
getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(TOKEN_KEY)
},
setToken(token: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(TOKEN_KEY, token)
},
removeToken(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(TOKEN_KEY)
},
getUser(): | {
( === )
userJson = .()
(!userJson)
{
.(userJson)
} {
}
},
(: ): {
( === )
.(, .(user))
},
(): {
( === )
.()
},
(): {
.()
.()
},
}
sessionTokenStorage = {
(): | {
( === )
.()
},
(: ): {
( === )
.(, token)
},
(): {
( === )
.()
},
(): | {
( === )
userJson = .()
(!userJson)
{
.(userJson)
} {
}
},
(: ): {
( === )
.(, .(user))
},
(): {
( === )
.()
},
(): {
.()
.()
},
}
Step 3: Create Auth API Client
Create src/lib/auth/api.ts:
import { apiClient } from '~/lib/api/client'
import type { LoginResponse, RegisterRequest, User } from './types'
export const authApi = {
async login(email: string, password: string): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/login', {
email,
password,
})
return response.data
},
async register(data: RegisterRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/register', data)
return response.data
},
async me(): Promise<User> {
const response = apiClient.<>()
response.
},
(): <> {
apiClient.()
},
}
Step 4: Create Auth Context
Create src/lib/auth/context.tsx:
import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { authApi } from './api'
import { tokenStorage } from './storage'
import type { AuthContextValue, User } from './types'
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const initAuth = async () => {
const storedToken = tokenStorage.()
storedUser = tokenStorage.()
(storedToken && storedUser) {
(storedToken)
(storedUser)
{
currentUser = authApi.()
(currentUser)
tokenStorage.(currentUser)
} (error) {
tokenStorage.()
()
()
}
}
()
}
()
}, [])
= () => {
{
response = authApi.(email, password)
tokenStorage.(response.)
tokenStorage.(response.)
(response.)
(response.)
({ : })
} (error) {
error
}
}
= () => {
{
response = authApi.({
email,
password,
: displayName,
})
tokenStorage.(response.)
tokenStorage.(response.)
(response.)
(response.)
({ : })
} (error) {
error
}
}
= () => {
tokenStorage.()
()
()
authApi.().( {
})
({ : })
}
= () => {
(!token)
{
currentUser = authApi.()
(currentUser)
tokenStorage.(currentUser)
} (error) {
()
}
}
: = {
user,
token,
: !!token && !!user,
isLoading,
login,
logout,
register,
refreshUser,
}
}
() {
context = ()
(context === ) {
()
}
context
}
Step 5: Wrap App with AuthProvider
Update src/routes/__root.tsx:
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { AuthProvider } from '~/lib/auth/context'
export const Route = createRootRoute({
component: () => (
<AuthProvider>
<Outlet />
</AuthProvider>
),
})
Step 6: Create Protected Route Component
Create src/components/auth/protected-route.tsx:
import { Navigate } from '@tanstack/react-router'
import { useAuth } from '~/lib/auth/context'
interface ProtectedRouteProps {
children: React.ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="mt-2 text-sm text-muted-foreground">Loading...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" />
}
return
}
Step 7: Implement Route Guards with beforeLoad
For better performance, use beforeLoad to check auth before loading route:
import { createFileRoute, redirect } from '@tanstack/react-router'
import { tokenStorage } from '~/lib/auth/storage'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ location }) => {
const token = tokenStorage.getToken()
if (!token) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
})
}
},
component: DashboardComponent,
})
function DashboardComponent() {
const { user } = useAuth()
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user?.display_name}!</p>
</div>
)
}
Step 8: Create Login Page
Create src/routes/login.tsx:
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useAuth } from '~/lib/auth/context'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { Label } from '~/components/ui/label'
export const Route = createFileRoute('/login')({
component: LoginPage,
})
function LoginPage() {
const { login, isAuthenticated } = useAuth()
const navigate = useNavigate()
const search = useSearch({ from: '/login' })
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
[isLoading, setIsLoading] = ()
(isAuthenticated) {
redirectTo = (search )?. ||
({ : redirectTo })
}
= () => {
e.()
()
()
{
(email, password)
} (err) {
(err ? err. : )
} {
()
}
}
(
)
}
Step 9: Integrate Auth Token with API Client
Update your API client to include auth token in requests:
import axios from 'axios'
import { tokenStorage } from '~/lib/auth/storage'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
apiClient.interceptors.request.use(
(config) => {
const token = tokenStorage.getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
(error.?. === ) {
tokenStorage.()
.. =
}
.(error)
}
)
Step 10: Create Logout Component
Create src/components/auth/logout-button.tsx:
import { useAuth } from '~/lib/auth/context'
import { Button } from '~/components/ui/button'
export function LogoutButton() {
const { logout, user } = useAuth()
return (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">{user?.email}</span>
<Button variant="outline" onClick={logout}>
Logout
</Button>
</div>
)
}
Advanced Patterns
Pattern 1: Role-Based Access Control
export interface User {
id: string
email: string
display_name: string
role: 'admin' | 'user' | 'guest'
}
import { tokenStorage } from './storage'
export function requireRole(role: 'admin' | 'user') {
return () => {
const user = tokenStorage.getUser()
if (!user) {
throw redirect({ to: '/login' })
}
if (user.role !== role && user.role !== 'admin') {
throw redirect({ to: '/unauthorized' })
}
}
}
export const Route = createFileRoute('/admin')({
beforeLoad: requireRole('admin'),
component: AdminPage,
})
Pattern 2: Refresh Token Handling
const REFRESH_TOKEN_KEY = 'refresh_token'
export const tokenStorage = {
getRefreshToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(REFRESH_TOKEN_KEY)
},
setRefreshToken(token: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(REFRESH_TOKEN_KEY, token)
},
removeRefreshToken(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(REFRESH_TOKEN_KEY)
},
}
let isRefreshing = false
let refreshSubscribers: ((token: ) => )[] = []
() {
refreshSubscribers.( (token))
refreshSubscribers = []
}
apiClient...(
response,
(error) => {
originalRequest = error.
(error.?. === && !originalRequest.) {
(isRefreshing) {
( {
refreshSubscribers.( {
originalRequest.. =
((originalRequest))
})
})
}
originalRequest. =
isRefreshing =
{
refreshToken = tokenStorage.()
(!refreshToken) {
()
}
response = axios.(, {
: refreshToken,
})
{ token } = response.
tokenStorage.(token)
(token)
isRefreshing =
originalRequest.. =
(originalRequest)
} (refreshError) {
tokenStorage.()
.. =
.(refreshError)
}
}
.(error)
}
)
Pattern 3: Remember Me Functionality
const login = async (email: string, password: string, rememberMe: boolean = false) => {
try {
const response = await authApi.login(email, password)
const storage = rememberMe ? tokenStorage : sessionTokenStorage
storage.setToken(response.token)
storage.setUser(response.user)
setToken(response.token)
setUser(response.user)
navigate({ to: '/dashboard' })
} catch (error) {
throw error
}
}
Pattern 4: Auth Loading Skeleton
import { Outlet } from '@tanstack/react-router'
import { useAuth } from '~/lib/auth/context'
export function AuthGuard() {
const { isLoading } = useAuth()
if (isLoading) {
return <AuthLoadingSkeleton />
}
return <Outlet />
}
function AuthLoadingSkeleton() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="space-y-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Authenticating...</p>
</div>
</div>
)
}
Security Best Practices
-
Never store sensitive data in localStorage
- Only store tokens, not passwords
- Consider using httpOnly cookies for production
-
Always use HTTPS in production
- Tokens sent over HTTP can be intercepted
-
Implement token expiration
- Short-lived access tokens (15 mins - 1 hour)
- Long-lived refresh tokens (days - weeks)
-
Validate tokens on every request
- Backend should verify token signature
- Check expiration time
-
Clear tokens on logout
- Remove from storage
- Invalidate on backend
-
Handle token theft
- Implement token rotation
- Monitor for suspicious activity
- Add device/IP tracking
Verification
Test your authentication implementation:
-
Login flow:
bun run dev
-
Protected routes:
-
Logout:
-
Token persistence:
-
API integration:
Troubleshooting
Issue: "useAuth must be used within AuthProvider"
Cause: Using useAuth() outside of <AuthProvider>.
Solution: Ensure __root.tsx wraps app with <AuthProvider>:
export const Route = createRootRoute({
component: () => (
<AuthProvider>
<Outlet />
</AuthProvider>
),
})
Issue: Infinite redirect loop
Cause: Protected route redirects to login, which redirects back.
Solution: Exclude /login from authentication checks:
export const Route = createFileRoute('/login')({
beforeLoad: ({ location }) => {
const token = tokenStorage.getToken()
if (token) {
throw redirect({ to: '/dashboard' })
}
},
})
Issue: Token persists after logout
Cause: Not clearing all storage.
Solution: Use tokenStorage.clear() in logout function.
Issue: 401 errors after page refresh
Cause: Token not loaded from storage on mount.
Solution: Verify useEffect in AuthProvider loads token from storage.
Project Structure
src/
├── routes/
│ ├── __root.tsx # AuthProvider wrapper
│ ├── login.tsx # Login page
│ ├── register.tsx # Registration page
│ └── dashboard.tsx # Protected route
├── lib/
│ ├── auth/
│ │ ├── types.ts # Auth type definitions
│ │ ├── context.tsx # AuthContext and AuthProvider
│ │ ├── storage.ts # Token storage utilities
│ │ ├── api.ts # Auth API client
│ │ └── guards.ts # Route guard utilities (optional)
│ └── api/
│ └── client.ts # Axios client with interceptors
└── components/
├── auth/
│ ├── protected-route.tsx # Protected route component
│ └── logout-button.tsx # Logout button
└── ui/ # shadcn/ui components
Notes
- This skill covers client-side token-based authentication
- For server-side session auth in TanStack Start (SSR), use
tanstack-start-auth skill
- Tokens are stored in localStorage by default (consider security implications)
- Always use HTTPS in production to protect tokens
- Implement refresh token rotation for enhanced security
- Consider using httpOnly cookies for production applications
Next Steps
After implementing authentication:
- Set up React Query with authenticated requests (see
tanstack-react-query-setup)
- Build registration and password reset flows
- Add multi-factor authentication (MFA)
- Implement OAuth/SSO providers
- Add user profile management
- Set up role-based access control (RBAC)
Resources