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
- Never store passwords - Hash on the server, never send to client
- Use HTTPS - Always in production
- Validate on server - Never trust client-side auth checks alone
- Handle token expiry - Refresh tokens before they expire
- Secure storage - Use httpOnly cookies for tokens when possible
- Clear state on logout - Reset all auth-related state