Next.js blurs the line between frontend and backend. Server Components execute on the server, API routes handle backend logic, and middleware runs at the edge. This hybrid model creates a unique security surface that requires different thinking than a traditional React SPA.
Security Headers
Next.js makes it straightforward to set security headers. Configure them in next.config.js:
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
},
];
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
The CSP above is a starting point. Tighten it based on your application's needs. Remove 'unsafe-eval' and 'unsafe-inline' wherever possible by using nonce-based CSP.
Server Components: The New Attack Surface
Next.js 13+ App Router uses React Server Components by default. These components execute on the server and send rendered HTML to the client. This is both a security advantage and a new risk.
Advantage: Sensitive logic and data fetching happen on the server. API keys, database queries, and business logic never reach the client.
Risk: Server Components can accidentally expose sensitive data if developers do not understand the boundary.
// app/dashboard/page.tsx - Server Component
async function DashboardPage() {
const secrets = process.env.DATABASE_URL; // This stays on the server
const data = await db.query('SELECT * FROM orders');
return <OrderList orders={data} />; // Only serializable data goes to the client
}
The critical rule: anything passed as props to a Client Component is serialized and sent to the browser. Never pass sensitive data as props to components marked with 'use client'.
// BAD: leaking server data to client
'use client';
function ClientComponent({ dbConnectionString }) {
// This string is now in the browser
}
// GOOD: only pass what the client needs
'use client';
function ClientComponent({ orderCount }) {
// Only the count, not the raw data or connection details
}
API Route Security
Input Validation
Every API route should validate its input. Use Zod for schema validation:
// app/api/users/route.ts
import { z } from 'zod';
import { NextResponse } from 'next/server';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['user', 'editor']),
});
export async function POST(request: Request) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
);
}
// Process validated data
const user = await createUser(result.data);
return NextResponse.json(user, { status: 201 });
}
Rate Limiting
Next.js does not include rate limiting out of the box. Implement it in middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export async function middleware(request) {
const ip = request.ip ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
Authentication in API Routes
Do not assume API routes are protected because the page that calls them requires auth. API routes are publicly accessible endpoints:
// app/api/admin/route.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function GET() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
// Admin-only logic
}
Server Actions Security
Server Actions ('use server') are functions that execute on the server but can be called from the client. They are essentially RPC endpoints:
'use server';
export async function updateProfile(formData: FormData) {
const session = await getServerSession();
if (!session) throw new Error('Unauthorized');
const name = formData.get('name');
// Validate and process
}
Critical security rules for Server Actions:
- Always authenticate. Server Actions are callable by anyone who can reach your server.
- Always validate input. FormData can contain anything.
- Never trust client-provided IDs. Verify ownership on the server.
- Apply rate limiting.
Environment Variables
Next.js exposes environment variables prefixed with NEXT_PUBLIC_ to the client. Everything else stays server-side:
# Server-only (safe for secrets)
DATABASE_URL=postgresql://...
API_SECRET=sk-...
# Client-accessible (never put secrets here)
NEXT_PUBLIC_API_BASE=https://api.example.com
Audit your .env files regularly. A misplaced NEXT_PUBLIC_ prefix can expose secrets.
Dependency Security
Next.js projects live in the npm ecosystem. The framework itself has a large dependency tree. Keep Next.js updated, as security patches are frequent. Use standard npm security practices: commit your lockfile, run audits in CI, and generate SBOMs.
How Safeguard.sh Helps
Safeguard.sh monitors the entire dependency chain of your Next.js applications. Beyond tracking npm package vulnerabilities, it provides visibility into which Next.js version each of your applications is running, flags applications on unsupported versions, and alerts you when framework-level CVEs are disclosed. For organizations running multiple Next.js services, Safeguard.sh gives your security team a single view of your full-stack JavaScript supply chain risk.