TanStack Start Authentication & Redirect Guide

A comprehensive guide to implementing authentication with automatic redirect-after-login functionality in TanStack Start.

TanStack Start Authentication & Redirect Guide

TanStack Start Authentication & Redirect Guide

A comprehensive guide to implementing authentication with automatic redirect-after-login functionality in TanStack Start.

Table of Contents


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:

  1. They get redirected to /sign-in
  2. 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

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>
  ),
});

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

  1. Type-safe search params with Zod validation
  2. Server-side middleware for protection
  3. Automatic redirects with path preservation
  4. Security validation to prevent open redirects
  5. Server functions for authentication
  6. Context passing for authenticated user data
  1. Use layout routes (__authenticated) for protected routes
  2. Apply optional auth at root for global user context
  3. Validate all redirect URLs for security
  4. Provide clear user feedback about redirects
  5. 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!


Additional Resources