Skip to content

TanStack Start

Complete auth setup for TanStack Start with ideal-auth.

Updated View as Markdown

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

  1. Install ideal-auth

    bun add ideal-auth
  2. Set the session secret

    Add a secret to your .env file. It must be at least 32 characters.

    .envbash
    IDEAL_AUTH_SECRET="at-least-32-characters-long-secret-here"

    Generate a strong secret:

    bunx ideal-auth secret

TanStack Start uses Vinxi under the hood. Use the cookie utilities from vinxi/http to build the bridge.

app/lib/cookies.tsts
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

Create a single auth() factory. Each server function calls auth() to get a fresh AuthInstance bound to the current request’s cookies.

app/lib/auth.tsts
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

app/lib/auth.actions.tsts
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

app/routes/login.tsxtsx
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

app/lib/auth.actions.tsts
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

app/lib/auth.actions.tsts
export const logoutFn = createServerFn({ method: 'POST' })
  .handler(async () => {
    const session = auth();
    await session.logout();
    return { success: true };
  });

Logout button

app/components/LogoutButton.tsxtsx
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

Create a server function to fetch the current user. This is used by route loaders and the beforeLoad guard.

app/lib/auth.actions.tsts
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)

Use beforeLoad to guard protected routes. This runs before the route’s loader, preventing unauthorized data fetching.

app/routes/dashboard.tsxtsx
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

To avoid repeating the beforeLoad logic, create a helper:

app/lib/auth-guard.tsts
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:

app/routes/settings.tsxtsx
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

In a route loader

app/routes/profile.tsxtsx
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

app/routes/_authed.tsxtsx
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.tsx

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)

app/middleware.tsts
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

app/lib/csrf.tsts
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

  • Session secret: Store IDEAL_AUTH_SECRET in environment variables. Never hard-code it or commit it to version control.
  • HTTPS: Set NODE_ENV=production in production so session cookies are automatically marked Secure.
  • Cookie scope: The default SameSite=Lax and HttpOnly=true settings protect against CSRF and XSS cookie theft.
  • Server function context: All vinxi/http cookie 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 in beforeLoad. 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 the beforeLoad logic.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close