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

Share

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 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

Next.js exposes variables with NEXT_PUBLIC_ prefix to the browser. Never put secrets in these:

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

Best Practice 2: Secure API Routes 5 min

API routes (App Router route handlers) need authentication and validation:

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

Server Actions are powerful but need careful security handling:

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 every request and is ideal for authentication:

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

Configure security headers in next.config.js:

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:

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

Handle file uploads carefully in Next.js API routes:

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?

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
Best Practices

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