Authentication Patterns
Next.js Development
Authentication Patterns
Authentication is a critical requirement for most web applications, and Next.js provides several integration points for implementing auth: middleware for route protection, Server Components for accessing session data, API routes for auth endpoints, and client components for login forms. The most popular approach is using NextAuth.js (now Auth.js), a complete authentication solution that supports dozens of providers. Understanding authentication patterns in Next.js means knowing how to protect routes, manage sessions across server and client, and handle the auth lifecycle from login to logout.
Session-Based Auth with Cookies
A common pattern is storing a session token in an HTTP-only cookie, validating it in middleware and Server Components.
// lib/auth.ts — authentication utilities
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
interface User {
id: number;
name: string;
email: string;
role: string;
}
interface Session {
user: User;
expires: string;
}
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies();
const token = cookieStore.get("session-token")?.value;
if (!token) return null;
try {
const res = await fetch(`${process.env.API_URL}/auth/session`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export async function requireAuth(): Promise<Session> {
const session = await getSession();
if (!session) {
redirect("/login");
}
return session;
}
Login API Route
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { email, password } = await request.json();
// Validate credentials against your backend or database
const res = await fetch(`${process.env.API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const error = await res.json();
return NextResponse.json(
{ error: error.message || "Invalid credentials" },
{ status: 401 }
);
}
const { token, user } = await res.json();
// Set HTTP-only cookie with the session token
const response = NextResponse.json({ user });
response.cookies.set("session-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
return response;
}
// app/api/auth/logout/route.ts
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.delete("session-token");
return response;
}
Protected Server Component
// app/dashboard/page.tsx
import { requireAuth } from "@/lib/auth";
export default async function DashboardPage() {
// Redirects to /login if not authenticated
const session = await requireAuth();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {session.user.name}!</p>
<p>Role: {session.user.role}</p>
</div>
);
}
Login Form (Client Component)
// components/LoginForm.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Login failed");
return;
}
router.push("/dashboard");
router.refresh(); // Refresh server components to reflect auth state
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<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={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
Using NextAuth.js
// NextAuth.js (Auth.js) provides a complete auth solution
// npm install next-auth
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GitHubProvider from "next-auth/providers/github";
import CredentialsProvider from "next-auth/providers/credentials";
const handler = NextAuth({
providers: [
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Validate against your database
const user = await validateUser(credentials);
return user || null;
},
}),
],
pages: { signIn: "/login" },
});
export { handler as GET, handler as POST };
Tip: Always use HTTP-only cookies for session tokens — they cannot be accessed by JavaScript, protecting against XSS attacks. Combine cookie-based auth with middleware for route protection and Server Components for data fetching with session context.
Key Takeaways
- Store session tokens in HTTP-only cookies for security against XSS attacks.
- Use middleware for route protection and Server Components for session-aware data fetching.
- Create auth utility functions (
getSession,requireAuth) for reuse across pages. - Call
router.refresh()after login/logout to update Server Components with new auth state. - NextAuth.js provides a complete solution with OAuth providers, session management, and database adapters.