← Back to Blog
Next.jsReactSecuritySSRFull-Stack

Next.js Security Best Practices: A Complete Guide for 2026

Introduction

Next.js 16 powers everything from static blogs to enterprise SaaS platforms. Its hybrid model — server components, API routes, middleware, and static exports — gives developers enormous flexibility. But that same flexibility creates a wider attack surface than a traditional single-page app.

A React SPA has one threat model: the browser. Next.js has three: the server (API routes, server actions, SSR), the network (middleware, rewrite rules, headers), and the client (hydration, CSP bypasses).

This guide covers the security patterns every Next.js developer should know in 2026. Each section includes production-ready code.


1. Content Security Policy with Nonces

A strict CSP is your strongest defense against XSS, but Next.js injects inline scripts for its bootstrap data (__NEXT_DATA__) and Route Segment preloads. Without a nonce, you're forced to use 'unsafe-inline' — which defeats the purpose.

Server-Side Nonce Generation

Generate a cryptographically random nonce once per request using middleware:

typescript
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  const nonce = crypto.randomUUID();

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' https: data: blob:`,
    `connect-src 'self' https://api.yoursite.com`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
  ].join("; ");

  const response = NextResponse.next();
  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

  // Pass nonce to the request so layout.tsx can use it
  response.headers.set("x-nonce", nonce);
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Applying the Nonce in Layout

tsx
// src/app/layout.tsx
import { headers } from "next/headers";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const headersList = await headers();
  const nonce = headersList.get("x-nonce") ?? "";

  return (
    <html lang="en">
      <head>
        <script nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `window.__NONCE__ = '${nonce}';`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

With 'strict-dynamic' in the CSP, any script loaded by an already-trusted script inherits trust — eliminating the need to nonce every inline script tag.


2. API Route Hardening

Every file in src/app/api/ is a publicly accessible HTTP endpoint. Common mistakes include missing authentication, no rate limiting, and trusting Content-Type headers.

Input Validation with Zod

Always validate request bodies before touching your database:

typescript
// src/app/api/contact/route.ts
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const contactSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  message: z.string().min(10).max(5000),
  // Prevent prototype pollution
  company: z.string().max(200).optional(),
});

export async function POST(req: NextRequest) {
  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const parsed = contactSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.flatten() },
      { status: 422 }
    );
  }
  // ... process parsed.data safely
}

Rate Limiting via Middleware

Use Vercel's KV or a simple in-memory map for smaller deployments:

typescript
// src/lib/rate-limit.ts
const rateMap = new Map<string, { count: number; resetAt: number }>();

export function rateLimit(ip: string, limit = 20, window = 60_000): boolean {
  const now = Date.now();
  const entry = rateMap.get(ip);

  if (!entry || now > entry.resetAt) {
    rateMap.set(ip, { count: 1, resetAt: now + window });
    return true;
  }

  if (entry.count >= limit) return false;
  entry.count++;
  return true;
}

Apply it in your middleware or within each API route handler.


3. Server Actions and CSRF Protection

Next.js 13+ introduced Server Actions — functions that run on the server but are callable from client components. They use POST internally and include a CSRF token automatically, but only for same-origin requests.

Safe Server Action Pattern

typescript
// src/app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

const feedbackSchema = z.object({
  rating: z.number().min(1).max(5),
  comment: z.string().max(2000),
});

export async function submitFeedback(formData: FormData) {
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for") ?? "unknown";

  if (!rateLimit(ip, 10)) {
    return { error: "Too many requests" };
  }

  const parsed = feedbackSchema.safeParse({
    rating: Number(formData.get("rating")),
    comment: formData.get("comment"),
  });

  if (!parsed.success) {
    return { error: "Invalid input" };
  }

  // Save to database here...

  revalidatePath("/feedback");
  return { success: true };
}

Key rule: Never call Server Actions from forms on other origins. Next.js embeds a CSRF token in the page, but only same-origin requests include it.


4. Data Fetching: Avoiding Serialization Risks

When fetching data in Server Components, you're running code on the server. A malicious API response can't execute arbitrary code, but it can cause:

  • Mass assignment attacks — leaking fields you didn't intend to expose
  • Prototype pollution — if you spread response data unsafely

Safe Data Selection

tsx
// src/app/page.tsx — Server Component
async function getData() {
  const res = await fetch("https://api.example.com/users");
  const users: unknown = await res.json();

  // Validate shape before using
  if (!Array.isArray(users)) return [];

  return users.map((u) => ({
    id: String(u?.id ?? ""),
    name: String(u?.name ?? "Unknown"),
    // Explicitly omit sensitive fields
  }));
}

Never do this:

tsx
// DANGEROUS: spreads entire API response client-side
const data = await getData();
return <ClientComponent data={data} />;
// If the API returns `{ ...user, isAdmin: true }`, it leaks

Instead, shape the data on the server before passing it to client components. Use Zod schemas to validate external API responses at the boundary.


5. Environment Variable Hygiene

Next.js blurs the line between server and client. A NEXT_PUBLIC_ prefix makes a variable available in browser bundles. Anything without that prefix stays server-only.

| Prefix | Available In | Example | |:-------|:-----------|:--------| | NEXT_PUBLIC_ | Client + Server | NEXT_PUBLIC_GA_ID | | (none) | Server only | DB_PASSWORD, API_SECRET |

Common Leak: Object Spread of process.env

tsx
// This leaks ALL environment variables to the client!
export default function Page() {
  return <pre>{JSON.stringify(process.env)}</pre>;
}

Guard Pattern

typescript
// src/env.ts
import { z } from "zod";

const serverEnv = z.object({
  DB_URL: z.string().url(),
  API_SECRET: z.string().min(1),
  NODE_ENV: z.enum(["development", "production", "test"]),
});

const clientEnv = z.object({
  NEXT_PUBLIC_GA_ID: z.string().optional(),
});

// Throw at build time if a required env var is missing
export const env = {
  ...serverEnv.parse(process.env),
  ...clientEnv.parse(process.env),
};

6. Static Export: The Smallest Attack Surface

If your application doesn't need a server — no API routes, no Server Actions, no SSR — export it as static HTML:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
};

A static export eliminates entire classes of attacks:

  • No server-side RCE via deserialization
  • No SSRF from API route fetch calls
  • No Server Action CSRF
  • No middleware bypasses

Serve the static output (out/ directory) behind a CDN with aggressive caching. Your only remaining concern is client-side XSS, which CSP handles.

This approach powers this very website (jssecurityaudit.com) — a Next.js static export served via nginx.


7. Security Checklist for Next.js

Use this checklist before every production deployment:

  • [ ] CSP with noncesscript-src 'nonce-{random}' (no 'unsafe-inline')
  • [ ] Middleware — CSP headers, rate limiting, security headers applied globally
  • [ ] API routes — Zod validation on all inputs, auth checks, rate limiting
  • [ ] Server Actions"use server" functions rate-limited and validated
  • [ ] No NEXT_PUBLIC_ leaks — checked with env.ts guard at build time
  • [ ] OTEL/audit logging — API access logged in production
  • [ ] Route handler authorization — never trust client-side roles
  • [ ] rewrites/redirects reviewed — no open redirects via user-controlled paths
  • [ ] Dependencies up to datenpm audit --audit-level=critical passes
  • [ ] Static export considered — if no server needed, output: "export" is safest

Conclusion

Next.js security isn't one thing — it's a chain of practices across the entire stack. CSP nonces lock down XSS. Zod schemas validate every API boundary. Middleware enforces rate limits and security headers. And every NEXT_PUBLIC_ variable is a conscious decision.

The most important principle: define your trust boundaries clearly. Know what runs on the server, what ships to the client, and where data crosses between them. Every crossing point is a potential vulnerability.

If you need a professional assessment of your Next.js application, book a security audit. We'll review your middleware configuration, API route hardening, CSP policy, and data flow — from next start to production.

Browse our full range of security audit services →

JS Security Audit — Professional React, Next.js, and Node.js security audits.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.