Documentation
Authentication
Auth Patterns

Auth Patterns

Authentication is one of the most critical parts of any application. Here are proven patterns for React.

Auth Context Pattern

// contexts/auth-context.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
 
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}
 
interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  register: (data: RegisterData) => Promise<void>;
}
 
const AuthContext = createContext<AuthContextType | null>(null);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
 
  // Check auth status on mount
  useEffect(() => {
    checkAuth();
  }, []);
 
  const checkAuth = async () => {
    try {
      const response = await fetch('/api/auth/me');
      if (response.ok) {
        const user = await response.json();
        setUser(user);
      }
    } catch (error) {
      console.error('Auth check failed:', error);
    } finally {
      setIsLoading(false);
    }
  };
 
  const login = async (email: string, password: string) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
 
    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Login failed');
    }
 
    const user = await response.json();
    setUser(user);
  };
 
  const logout = async () => {
    await fetch('/api/auth/logout', { method: 'POST' });
    setUser(null);
  };
 
  const register = async (data: RegisterData) => {
    const response = await fetch('/api/auth/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
 
    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Registration failed');
    }
 
    const user = await response.json();
    setUser(user);
  };
 
  return (
    <AuthContext.Provider
      value={{
        user,
        isLoading,
        isAuthenticated: !!user,
        login,
        logout,
        register,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Using Auth Hook

// components/user-menu.tsx
function UserMenu() {
  const { user, logout, isAuthenticated } = useAuth();
 
  if (!isAuthenticated) {
    return <Link href="/login">Login</Link>;
  }
 
  return (
    <div>
      <span>Welcome, {user?.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Zustand Auth Store

For more complex apps, Zustand provides better performance:

// stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
 
interface User {
  id: string;
  email: string;
  name: string;
  role: string;
}
 
interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  setUser: (user: User | null) => void;
}
 
export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isAuthenticated: false,
 
      login: async (email, password) => {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email, password }),
        });
 
        if (!response.ok) {
          throw new Error('Login failed');
        }
 
        const { user, token } = await response.json();
        set({ user, token, isAuthenticated: true });
      },
 
      logout: () => {
        set({ user: null, token: null, isAuthenticated: false });
      },
 
      setUser: (user) => {
        set({ user, isAuthenticated: !!user });
      },
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({ token: state.token }), // Only persist token
    }
  )
);

Login Form

// components/forms/login-form.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuth } from '@/contexts/auth-context';
import { useRouter } from 'next/router';
 
const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(1, 'Password is required'),
});
 
type LoginForm = z.infer<typeof loginSchema>;
 
export function LoginForm() {
  const { login } = useAuth();
  const router = useRouter();
  const returnUrl = (router.query.returnUrl as string) || '/dashboard';
 
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
  });
 
  const onSubmit = async (data: LoginForm) => {
    try {
      await login(data.email, data.password);
      router.push(returnUrl);
    } catch (error) {
      setError('root', {
        message: error instanceof Error ? error.message : 'Login failed',
      });
    }
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      {errors.root && (
        <div className="bg-red-50 text-red-600 p-3 rounded">
          {errors.root.message}
        </div>
      )}
 
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          autoComplete="email"
        />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>
 
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          autoComplete="current-password"
        />
        {errors.password && <span className="error">{errors.password.message}</span>}
      </div>
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

Role-Based Access

// hooks/use-permissions.ts
import { useAuth } from '@/contexts/auth-context';
 
type Permission = 'read' | 'write' | 'delete' | 'admin';
 
const rolePermissions: Record<string, Permission[]> = {
  admin: ['read', 'write', 'delete', 'admin'],
  editor: ['read', 'write'],
  viewer: ['read'],
};
 
export function usePermissions() {
  const { user } = useAuth();
 
  const hasPermission = (permission: Permission): boolean => {
    if (!user) return false;
    return rolePermissions[user.role]?.includes(permission) ?? false;
  };
 
  const hasAnyPermission = (permissions: Permission[]): boolean => {
    return permissions.some(hasPermission);
  };
 
  const hasAllPermissions = (permissions: Permission[]): boolean => {
    return permissions.every(hasPermission);
  };
 
  return {
    hasPermission,
    hasAnyPermission,
    hasAllPermissions,
    isAdmin: hasPermission('admin'),
    canWrite: hasPermission('write'),
    canDelete: hasPermission('delete'),
  };
}
 
// Usage
function AdminPanel() {
  const { isAdmin } = usePermissions();
 
  if (!isAdmin) {
    return <div>Access denied</div>;
  }
 
  return <div>Admin content</div>;
}

Permission Component

// components/auth/can.tsx
import { usePermissions } from '@/hooks/use-permissions';
 
interface CanProps {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}
 
export function Can({ permission, children, fallback = null }: CanProps) {
  const { hasPermission } = usePermissions();
 
  if (!hasPermission(permission)) {
    return <>{fallback}</>;
  }
 
  return <>{children}</>;
}
 
// Usage
function PostActions({ post }) {
  return (
    <div>
      <Can permission="write">
        <button>Edit</button>
      </Can>
 
      <Can permission="delete" fallback={<span>Cannot delete</span>}>
        <button>Delete</button>
      </Can>
    </div>
  );
}

Auth Loading State

// components/auth/auth-guard.tsx
import { useAuth } from '@/contexts/auth-context';
 
export function AuthGuard({ children }: { children: React.ReactNode }) {
  const { isLoading } = useAuth();
 
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
      </div>
    );
  }
 
  return <>{children}</>;
}
 
// Usage in _app.tsx
function App({ Component, pageProps }) {
  return (
    <AuthProvider>
      <AuthGuard>
        <Component {...pageProps} />
      </AuthGuard>
    </AuthProvider>
  );
}

Best Practices

  1. Never store passwords - Hash on the server, never send to client
  2. Use HTTPS - Always in production
  3. Validate on server - Never trust client-side auth checks alone
  4. Handle token expiry - Refresh tokens before they expire
  5. Secure storage - Use httpOnly cookies for tokens when possible
  6. Clear state on logout - Reset all auth-related state