TanStack Start Authentication & Redirect Guide
A comprehensive guide to implementing authentication with automatic redirect-after-login functionality in TanStack Start.
Table of Contents
- Overview
- Basic Implementation
- Middleware Strategies
- Advanced Patterns
- Security Considerations
- Best Practices
Overview
This guide demonstrates how to build a redirect feature that remembers where users tried to go before being redirected to sign in, then automatically returns them to that destination after successful authentication.
The Problem
When a user tries to navigate to /blog but isn't authenticated:
- They get redirected to
/sign-in - After signing in, they should automatically go to
/blog(not a default page)
The Solution
Capture the intended URL before redirecting, then use it after authentication.
Basic Implementation
1. Authentication Middleware
// app/middleware/auth.ts
import { createMiddleware } from '@tanstack/start';
import { redirect } from '@tanstack/react-router';
export const authMiddleware = createMiddleware().server(async ({ next, context }) => {
const session = await getSession(context.request);
if (!session) {
// Get the current URL path
const url = new URL(context.request.url);
const pathname = url.pathname;
// Redirect to sign-in with the intended destination
throw redirect({
to: '/sign-in',
search: {
redirect: pathname
}
});
}
return next({ context: { ...context, session } });
});
2. Protected Route Example
// app/routes/blog.tsx
import { createFileRoute } from '@tanstack/react-router';
import { authMiddleware } from '../middleware/auth';
export const Route = createFileRoute('/blog')({
beforeLoad: authMiddleware,
component: BlogPage,
});
function BlogPage() {
return (
<div>
<h1>Blog</h1>
<p>Protected blog content</p>
</div>
);
}
3. Sign-In Route with Redirect Logic
// app/routes/sign-in.tsx
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
import { useState } from 'react';
import { z } from 'zod';
// Define search params schema
const signInSearchSchema = z.object({
redirect: z.string().optional().catch(undefined),
});
export const Route = createFileRoute('/sign-in')({
validateSearch: signInSearchSchema,
component: SignInPage,
});
function SignInPage() {
const navigate = useNavigate();
const search = useSearch({ from: '/sign-in' });
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Your sign-in logic here
await signIn({ email, password });
// Get the redirect path and validate it
const redirectPath = getValidatedRedirect(search.redirect);
// Navigate to the intended destination
await navigate({ to: redirectPath });
} catch (error) {
console.error('Sign in failed:', error);
// Handle error
} finally {
setIsLoading(false);
}
};
return (
<div className="sign-in-container">
<h1>Sign In</h1>
{search.redirect && (
<p className="redirect-notice">
Please sign in to continue to {search.redirect}
</p>
)}
<form onSubmit={handleSignIn}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
);
}
// Security: Validate redirect URL
function getValidatedRedirect(redirect?: string): string {
// Default fallback
const defaultPath = '/dashboard';
if (!redirect) return defaultPath;
// Only allow relative paths
if (redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect;
}
// If invalid, return default
return defaultPath;
}
4. Server-Side Sign-In Action
// app/routes/api/sign-in.ts
import { createAPIFileRoute } from '@tanstack/start/api';
import { json } from '@tanstack/start';
export const Route = createAPIFileRoute('/api/sign-in')({
POST: async ({ request }) => {
const body = await request.json();
// Your authentication logic
const session = await authenticateUser(body.email, body.password);
if (!session) {
return json({ error: 'Invalid credentials' }, { status: 401 });
}
// Set session cookie
const headers = new Headers();
headers.set('Set-Cookie', await createSessionCookie(session));
return json({ success: true }, { headers });
},
});
5. Alternative: Using Server Functions
// app/routes/sign-in.tsx
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/start';
import { useState } from 'react';
// Server function for sign-in
const signInFn = createServerFn('POST', async (data: { email: string; password: string }) => {
const session = await authenticateUser(data.email, data.password);
if (!session) {
throw new Error('Invalid credentials');
}
// Set session
await setSession(session);
return { success: true };
});
export const Route = createFileRoute('/sign-in')({
component: SignInPage,
});
function SignInPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
try {
await signInFn({ email, password });
// Validate and redirect
const redirectPath = search.redirect?.startsWith('/') && !search.redirect.startsWith('//')
? search.redirect
: '/dashboard';
navigate({ to: redirectPath });
} catch (error) {
console.error('Sign in failed:', error);
}
};
return (
<form onSubmit={handleSignIn}>
{/* Form fields */}
</form>
);
}
Middleware Strategies
Strategy 1: Route Tree Middleware (Recommended)
Use a layout route to apply middleware to multiple child routes:
// app/routes/__authenticated.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { authMiddleware } from '../middleware/auth';
export const Route = createFileRoute('/__authenticated')({
beforeLoad: authMiddleware,
component: () => <Outlet />, // Renders child routes
});
Then nest protected routes under it:
// app/routes/__authenticated/blog.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/__authenticated/blog')({
component: BlogPage,
});
function BlogPage() {
return <div>Protected Blog Content</div>;
}
// app/routes/__authenticated/profile.tsx
export const Route = createFileRoute('/__authenticated/profile')({
component: ProfilePage,
});
// app/routes/__authenticated/dashboard.tsx
export const Route = createFileRoute('/__authenticated/dashboard')({
component: DashboardPage,
});
Folder Structure:
app/routes/
__authenticated/
blog.tsx
profile.tsx
dashboard.tsx
sign-in.tsx
index.tsx
All routes under /__authenticated will automatically run the auth middleware!
Strategy 2: Multiple Layout Routes for Different Protection Levels
// app/routes/__authenticated.tsx
export const Route = createFileRoute('/__authenticated')({
beforeLoad: authMiddleware,
component: () => <Outlet />,
});
// app/routes/__admin.tsx
export const Route = createFileRoute('/__admin')({
beforeLoad: async (opts) => {
await authMiddleware(opts);
await adminMiddleware(opts);
},
component: () => <Outlet />,
});
// app/routes/__public.tsx (explicitly public)
export const Route = createFileRoute('/__public')({
component: () => <Outlet />,
});
Folder Structure:
app/routes/
__authenticated/
blog.tsx
profile.tsx
dashboard.tsx
__admin/
users.tsx
settings.tsx
__public/
about.tsx
contact.tsx
sign-in.tsx
index.tsx
Strategy 3: Root Route Middleware (Global)
Apply middleware at the root level, then opt-out for public routes:
// app/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { authMiddleware } from '../middleware/auth';
export const Route = createRootRoute({
beforeLoad: async (opts) => {
// List of public routes that don't need auth
const publicRoutes = ['/sign-in', '/sign-up', '/forgot-password', '/'];
if (!publicRoutes.includes(opts.location.pathname)) {
await authMiddleware(opts);
}
},
component: () => (
<div>
<Outlet />
</div>
),
});
Strategy 4: Hybrid Pattern (Recommended)
Combine layout routes with explicit configuration:
// app/middleware/auth.ts
import { createMiddleware } from '@tanstack/start';
import { redirect } from '@tanstack/react-router';
export const authMiddleware = createMiddleware().server(async ({ next, context }) => {
const session = await getSession(context.request);
if (!session) {
const url = new URL(context.request.url);
throw redirect({
to: '/sign-in',
search: {
redirect: url.pathname + url.search,
},
});
}
return next({
context: {
...context,
session,
user: session.user
}
});
});
export const optionalAuthMiddleware = createMiddleware().server(async ({ next, context }) => {
const session = await getSession(context.request);
// Don't redirect, just pass session if it exists
return next({
context: {
...context,
session: session || null,
user: session?.user || null
}
});
});
// app/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { optionalAuthMiddleware } from '../middleware/auth';
export const Route = createRootRoute({
beforeLoad: optionalAuthMiddleware, // Makes auth available everywhere
component: RootComponent,
});
function RootComponent() {
return (
<div>
<Header />
<Outlet />
</div>
);
}
// app/routes/__authenticated.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { authMiddleware } from '../middleware/auth';
export const Route = createFileRoute('/__authenticated')({
beforeLoad: authMiddleware, // Enforces auth for child routes
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
const { user } = Route.useRouteContext();
return (
<div>
<AuthenticatedNav user={user} />
<Outlet />
</div>
);
}
File Structure:
app/routes/
__root.tsx # Optional auth for all
__authenticated/ # Required auth
blog.tsx
profile.tsx
dashboard.tsx
settings/
index.tsx
account.tsx
sign-in.tsx # Public
sign-up.tsx # Public
index.tsx # Public
about.tsx # Public
Advanced Patterns
Enhanced Auth Middleware with Context
// app/middleware/auth.ts
import { createMiddleware } from '@tanstack/start';
import { redirect } from '@tanstack/react-router';
export const authMiddleware = createMiddleware().server(async ({ next, context }) => {
const session = await getSession(context.request);
if (!session) {
const url = new URL(context.request.url);
throw redirect({
to: '/sign-in',
search: {
redirect: url.pathname + url.search, // Include query params
},
});
}
return next({
context: {
...context,
session,
user: session.user
}
});
});
// Optional: Role-based middleware
export const requireRole = (role: string) => {
return createMiddleware().server(async ({ next, context }) => {
const session = await getSession(context.request);
if (!session || !session.user.roles.includes(role)) {
throw redirect({
to: '/unauthorized',
search: {
redirect: new URL(context.request.url).pathname
}
});
}
return next({ context: { ...context, session } });
});
};
Middleware Composition
Create reusable middleware combinations:
// app/middleware/compose.ts
import { authMiddleware } from './auth';
import { rateLimitMiddleware } from './rateLimit';
import { loggingMiddleware } from './logging';
export const composeMiddleware = (...middlewares: any[]) => {
return async (opts: any) => {
for (const middleware of middlewares) {
await middleware(opts);
}
};
};
// Usage
export const Route = createFileRoute('/__authenticated')({
beforeLoad: composeMiddleware(
loggingMiddleware,
rateLimitMiddleware,
authMiddleware
),
component: () => <Outlet />,
});
Role-Based Protection
// app/routes/__admin.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { authMiddleware } from '../middleware/auth';
import { redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/__admin')({
beforeLoad: async (opts) => {
// First check auth
const result = await authMiddleware(opts);
// Then check role
if (!result.context.user.roles.includes('admin')) {
throw redirect({
to: '/unauthorized',
search: {
redirect: opts.location.pathname,
},
});
}
return result;
},
component: () => <Outlet />,
});
Complete Working Example
// app/routes/protected/profile.tsx
import { createFileRoute } from '@tanstack/react-router';
import { authMiddleware } from '../../middleware/auth';
export const Route = createFileRoute('/protected/profile')({
beforeLoad: authMiddleware,
component: ProfilePage,
});
function ProfilePage() {
const { user } = Route.useRouteContext();
return (
<div>
<h1>Welcome, {user.name}!</h1>
<p>This is your protected profile page.</p>
</div>
);
}
Security Considerations
Validate Redirect URLs
Always validate redirect URLs to prevent open redirect vulnerabilities:
function getValidatedRedirect(redirect?: string): string {
const defaultPath = '/dashboard';
if (!redirect) return defaultPath;
// Only allow relative paths
if (redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect;
}
return defaultPath;
}
Advanced Validation
function isValidRedirect(url: string): boolean {
// Only allow relative URLs
if (url.startsWith('/') && !url.startsWith('//')) {
return true;
}
// Or check against allowed domains
try {
const parsed = new URL(url, window.location.origin);
return parsed.origin === window.location.origin;
} catch {
return false;
}
}
// Use it
const redirectPath = isValidRedirect(redirect) ? redirect : '/dashboard';
Whitelist Approach
const ALLOWED_REDIRECTS = [
'/dashboard',
'/profile',
'/settings',
'/blog',
// Add other allowed paths
];
function getValidatedRedirect(redirect?: string): string {
if (!redirect) return '/dashboard';
// Check if the redirect path is in the whitelist
if (ALLOWED_REDIRECTS.some(path => redirect.startsWith(path))) {
return redirect;
}
return '/dashboard';
}
Best Practices
1. Middleware Strategy
✅ Use layout routes (__authenticated) for protecting multiple routes
- Most maintainable approach
- Makes protection explicit in file structure
- Easy to understand
- Supports multiple protection levels
- Type-safe context inheritance
2. Query Parameters vs Storage
✅ Use query parameters for redirect URLs
- ✅ Simple and transparent
- ✅ Shareable URLs
- ✅ Works across tabs/windows
- ✅ No storage cleanup needed
❌ Avoid sessionStorage/localStorage
- ❌ Harder to debug
- ❌ Not shareable
- ❌ Requires cleanup
- ❌ Can have stale data
3. Default Fallbacks
Always provide a default fallback route:
const redirectPath = validatedRedirect || '/dashboard';
4. Security First
Always validate redirect URLs:
- Prevent open redirect attacks
- Only allow relative paths
- Use whitelist when possible
5. User Experience
Provide feedback to users:
{search.redirect && (
<p className="redirect-notice">
Please sign in to continue to {search.redirect}
</p>
)}
6. Include Query Parameters
Don't forget to preserve query parameters:
throw redirect({
to: '/sign-in',
search: {
redirect: url.pathname + url.search, // Include query params
},
});
7. Use replace for Clean History
Avoid back-button issues:
navigate({ to: redirectPath, replace: true });
Summary
Key Features
- Type-safe search params with Zod validation
- Server-side middleware for protection
- Automatic redirects with path preservation
- Security validation to prevent open redirects
- Server functions for authentication
- Context passing for authenticated user data
Recommended Approach
- Use layout routes (
__authenticated) for protected routes - Apply optional auth at root for global user context
- Validate all redirect URLs for security
- Provide clear user feedback about redirects
- Use server functions for authentication logic
Architecture Overview
app/
├── middleware/
│ ├── auth.ts # Auth middleware
│ └── compose.ts # Middleware composition
├── routes/
│ ├── __root.tsx # Optional auth
│ ├── __authenticated/ # Protected routes
│ │ ├── blog.tsx
│ │ ├── profile.tsx
│ │ └── dashboard.tsx
│ ├── sign-in.tsx # Public
│ └── index.tsx # Public
└── utils/
└── validation.ts # URL validation
This architecture provides a secure, maintainable, and user-friendly authentication system with automatic redirect functionality!
