TL;DR
The #1 Next.js security best practice is understanding and respecting the server/client boundary. These 7 practices take about 30 minutes to implement and prevent 84% of security issues found in Next.js applications. Focus on: keeping secrets in server-only code, protecting API routes with authentication middleware, using Server Actions carefully, and adding security headers.
"In Next.js, security is architecture. Know what runs where, and you'll know what's safe where."
Understanding the Server/Client Boundary 3 min
Next.js runs code on both server and client. Security issues often arise from confusing which code runs where.
| Code Location | Runs On | Can Access Secrets? |
|---|---|---|
| Server Components | Server only | Yes |
| API Routes / Route Handlers | Server only | Yes |
| Server Actions | Server only | Yes |
| Middleware | Edge/Server | Yes |
| Client Components ('use client') | Browser | No (only NEXT_PUBLIC_) |
Best Practice 1: Protect Environment Variables 2 min
Next.js exposes variables with NEXT_PUBLIC_ prefix to the browser. Never put secrets in these:
# Server-side only (safe for secrets)
DATABASE_URL=postgresql://user:pass@host/db
STRIPE_SECRET_KEY=sk_live_xxx
JWT_SECRET=your-secret-key
# Client-side exposed (only public values)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_APP_URL=https://yourdomain.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
Critical: Any NEXT_PUBLIC_ variable is bundled into client JavaScript and visible to users. Never put API keys, database URLs, or secrets in NEXT_PUBLIC_ variables.
Best Practice 2: Secure API Routes 5 min
API routes (App Router route handlers) need authentication and validation:
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { z } from 'zod';
const updateSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
});
export async function GET(request: NextRequest) {
// Check authentication
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Return only user's own data
const userData = await db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true }
});
return NextResponse.json(userData);
}
export async function PUT(request: NextRequest) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Validate input
const body = await request.json();
const result = updateSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', issues: result.error.issues },
{ status: 400 }
);
}
// Update user's own data only
await db.user.update({
where: { id: session.user.id },
data: result.data,
});
return NextResponse.json({ success: true });
}
Best Practice 3: Secure Server Actions 5 min
Server Actions are powerful but need careful security handling:
'use server';
import { getServerSession } from 'next-auth';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().max(50000),
});
export async function createPost(formData: FormData) {
// Always verify authentication
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Validate input
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
const result = postSchema.safeParse(rawData);
if (!result.success) {
return { error: 'Invalid input' };
}
// Create with verified user ID
await db.post.create({
data: {
...result.data,
authorId: session.user.id, // Never trust client-provided user ID
},
});
revalidatePath('/posts');
return { success: true };
}
Important: Server Actions can be called directly by any client. Always validate authentication and never trust any data passed from the client.
Best Practice 4: Use Middleware for Route Protection 5 min
Middleware runs before every request and is ideal for authentication:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin');
// Redirect logged-in users away from auth pages
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Protect dashboard routes
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Admin routes require admin role
if (isAdminRoute) {
if (!token || token.role !== 'admin') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/login'],
};
Best Practice 5: Add Security Headers 3 min
Configure security headers in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
];
},
};
module.exports = nextConfig;
Best Practice 6: Prevent Data Leaks in Server Components 3 min
Server Components can accidentally expose sensitive data:
// app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await getServerSession();
if (!session) {
redirect('/login');
}
// Fetch only current user's data
const userData = await db.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
// Never select: password, tokens, etc.
},
});
return (
<Dashboard user={userData} />
);
}
Best Practice 7: Secure File Uploads 5 min
Handle file uploads carefully in Next.js API routes:
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: NextRequest) {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 });
}
// Validate file size
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large' }, { status: 400 });
}
// Generate safe filename
const ext = file.name.split('.').pop();
const safeFilename = `${crypto.randomUUID()}.${ext}`;
// Upload to secure storage (S3, etc.)
// ...
return NextResponse.json({ success: true, filename: safeFilename });
}
Common Next.js Security Mistakes
| Mistake | Impact | Prevention |
|---|---|---|
| Secrets in NEXT_PUBLIC_ vars | Credential exposure | Use server-only env vars |
| Unprotected API routes | Unauthorized access | Add auth to every route |
| Trusting Server Action input | Data manipulation | Always validate and verify auth |
| Exposing user data in props | Information disclosure | Select only needed fields |
| Missing security headers | XSS, clickjacking | Configure in next.config.js |
Official Resources: For the latest information, see Next.js Authentication Documentation, Server Actions Guide, and Security Headers Configuration.
Are Server Components secure by default?
Server Components run on the server so they can access secrets safely. However, data passed to Client Components as props is serialized and sent to the browser, so be careful what you pass down.
How do I protect API routes in App Router?
Use getServerSession or your auth library in each route handler to verify authentication. Consider creating a reusable auth wrapper function to reduce repetition and ensure consistent protection.
Are Server Actions safe to use?
Server Actions are secure for the server-side operations they perform, but they can be called by any client with any data. Always authenticate the user and validate all inputs within the action itself.
Should I use Middleware or API route checks for auth?
Use both. Middleware provides a first line of defense and handles redirects. API routes should still verify authentication because Middleware can be bypassed in some edge cases.
Verify Your Next.js Security
Scan your Next.js project for security issues and misconfigurations.
Start Free Scan