Skip to content

Authentication

Problem

Implement authentication in a production-friendly way with @vielzeug/routeit while keeping setup and cleanup explicit.

Runnable Example

The snippet below is copy-paste runnable in a TypeScript project with @vielzeug/routeit installed.

Complete authentication flow with protected routes.

ts
import { createRouter } from '@vielzeug/routeit';
import type { Middleware } from '@vielzeug/routeit';

interface User {
  id: string;
  name: string;
  roles: string[];
}

const authService = {
  async getCurrentUser(): Promise<User | null> {
    const token = localStorage.getItem('authToken');
    if (!token) return null;
    const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } });
    return res.ok ? res.json() : null;
  },
  async login(email: string, password: string): Promise<User> {
    const res = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    localStorage.setItem('authToken', data.token);
    return data.user;
  },
  logout() {
    localStorage.removeItem('authToken');
  },
};

const router = createRouter();

// Load authenticated user into ctx.locals so downstream handlers can read it
const requireAuth: Middleware = async (ctx, next) => {
  const user = await authService.getCurrentUser();
  if (!user) {
    await ctx.navigate('/login', { replace: true });
    return;
  }
  ctx.locals.user = user;
  await next();
};

const requireRole =
  (role: string): Middleware =>
  async (ctx, next) => {
    const user = ctx.locals.user as User | undefined;
    if (!user?.roles.includes(role)) {
      await ctx.navigate('/forbidden', { replace: true });
      return;
    }
    await next();
  };

router
  .on('/', () => renderHome())
  .on('/login', () => renderLogin())
  .on('/forbidden', () => renderForbidden())

  // Protected routes
  .group(
    '/dashboard',
    (r) => {
      r.on('/', (ctx) => {
        const user = ctx.locals.user as User;
        renderDashboard(user);
      });
      r.on('/profile', (ctx) => renderProfile(ctx.locals.user as User));
    },
    { middleware: requireAuth },
  )

  // Admin-only routes
  .group(
    '/admin',
    (r) => {
      r.on('/reports', renderReports);
      r.on('/users', renderAdminUsers);
    },
    { middleware: [requireAuth, requireRole('admin')] },
  )

  .start();

Expected Output

  • The example runs without type errors in a standard TypeScript setup.
  • The main flow produces the behavior described in the recipe title.

Common Pitfalls

  • Forgetting cleanup/dispose calls can leak listeners or stale state.
  • Skipping explicit typing can hide integration issues until runtime.
  • Not handling error branches makes examples harder to adapt safely.