Next.js Security Best Practices: API Routes, Auth, and Data Protection

TL;DR

Almost every Next.js security problem is the server/client boundary being crossed by accident. The seven practices below take about 30 minutes. Keep secrets in server-only code, put authentication in every API route, treat Server Actions as public endpoints, and set your 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 your code in two places. Most security problems here start with losing track of which place a given file is in.

Code LocationRuns OnCan Access Secrets?
Server ComponentsServer onlyYes
API Routes / Route HandlersServer onlyYes
Server ActionsServer onlyYes
MiddlewareEdge/ServerYes
Client Components ('use client')BrowserNo (only NEXT_PUBLIC_)

Best Practice 1: Protect Environment Variables 2 min

Any variable prefixed NEXT_PUBLIC_ gets compiled into the JavaScript you ship. It's a publishing instruction, not a naming convention. Nothing secret goes in one:

.env.local (correct usage)
# 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.

When a scan turns up a live secret in a Next.js app, a NEXT_PUBLIC_ prefix is usually how it got there. The app works, nothing warns you, and the key ships in a file anyone can download.

Best Practice 2: Secure API Routes 5 min

Route handlers are public URLs. Anyone can call one with anything, so each needs to check who is asking and what they sent:

app/api/user/route.ts
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

A Server Action looks like a function call in your component. It isn't. It compiles down to an HTTP endpoint that accepts whatever the caller sends:

Secure Server Action pattern
'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 the request reaches your route, which makes it the right place for redirects and a first pass at auth:

middleware.ts
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

Headers are the cheapest thing on this list. Set them once in next.config.js and forget them:

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 read secrets safely. The leak happens on the way out, when a row you fetched gets handed to a Client Component as a prop and serialized into the page:

Safe data fetching in Server Components
// 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

Uploads need three checks before the file goes anywhere: who sent it, what it claims to be, and how big it is:

Secure file upload handler
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

MistakeImpactPrevention
Secrets in NEXT_PUBLIC_ varsCredential exposureUse server-only env vars
Unprotected API routesUnauthorized accessAdd auth to every route
Trusting Server Action inputData manipulationAlways validate and verify auth
Exposing user data in propsInformation disclosureSelect only needed fields
Missing security headersXSS, clickjackingConfigure 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?

Yes, for what they read. Secrets are safe inside a Server Component. The risk is on the way out: props handed to a Client Component get serialized into the page, so pass fields, not whole rows.

How do I protect API routes in App Router?

Call getServerSession, or your auth library, inside every route handler. A shared wrapper is worth writing early: the route people forget to protect is always the one added last.

Are Server Actions safe to use?

They're safe to use, but they aren't private. Any client can call one with any payload, so the action itself has to check the session and validate what it received.

Should I use Middleware or API route checks for auth?

Both. Middleware handles redirects and catches most unauthenticated traffic early. It can be bypassed, though, so the route still has to check for itself.

Further Reading

Put these practices into action with our step-by-step guides.

Verify Your Next.js Security

Scan your Next.js project for security issues and misconfigurations.

Best Practices

Next.js Security Best Practices: API Routes, Auth, and Data Protection