DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Rate Limiting and API Security in Next.js 15 The Patterns I Actually Use

An API route with no rate limiting works fine right up until someone hits it a thousand times a second, either by accident (a buggy client retry loop) or on purpose. I learned this the annoying way, watching a client's database bill spike because a public form endpoint had no limit on how often it could be called.

Here is what I actually put in place now, on every project with public-facing endpoints.


1. Rate Limiting with Upstash

Upstash's Redis-backed rate limiter works well with Next.js since it is designed for serverless, no persistent connection required between requests.

npm install @upstash/ratelimit @upstash/redis
Enter fullscreen mode Exit fullscreen mode
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL as string,
  token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
});

export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '60 s'),
  analytics: true,
});
Enter fullscreen mode Exit fullscreen mode

slidingWindow(10, '60 s') allows 10 requests per 60 seconds per identifier. Sliding window is generally the better default over a fixed window, since a fixed window lets someone burst right at the boundary between two windows and effectively double their allowed rate.


2. Applying It to a Route Handler

// app/api/contact/route.ts
import { ratelimit } from '@/lib/ratelimit';
import { headers } from 'next/headers';

export async function POST(request: Request) {
  const headersList = await headers();
  const ip = headersList.get('x-forwarded-for') ?? 'unknown';

  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return Response.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  const body = await request.json();
  // handle the actual request
  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

Returning the rate limit headers even on success is good practice, it lets well-behaved clients see how close they are to the limit and back off proactively instead of finding out by hitting a 429.


3. Rate Limiting Server Actions

Server Actions do not get the same automatic IP access that route handlers do, since they are not standard HTTP endpoints in the same way. Getting the identifier takes an extra step.

// actions/contact.ts
'use server';
import { ratelimit } from '@/lib/ratelimit';
import { headers } from 'next/headers';

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

  const { success } = await ratelimit.limit(`contact_${ip}`);

  if (!success) {
    return { success: false, message: 'Too many attempts, try again shortly' };
  }

  // proceed with validation and saving
  return { success: true, message: 'Message sent' };
}
Enter fullscreen mode Exit fullscreen mode

Prefixing the identifier with contact_ matters if multiple Server Actions share one rate limiter, it keeps each action's usage counted separately instead of one action's traffic eating into another's limit.


4. Rate Limiting by User, Not Just IP

IP-based limiting is the right default for unauthenticated endpoints, like a public contact form. For authenticated actions, limiting by user ID is usually more accurate, since multiple users can share an IP behind the same network, and one user can rotate IPs to dodge an IP-based limit entirely.

// actions/posts.ts
'use server';
import { ratelimit } from '@/lib/ratelimit';
import { getSession } from '@/lib/auth';

export async function createPost(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error('Not authenticated');

  const { success } = await ratelimit.limit(`create_post_${session.userId}`);

  if (!success) {
    return { success: false, message: 'Slow down, try again in a minute' };
  }

  // proceed
}
Enter fullscreen mode Exit fullscreen mode

5. Validating Input Before It Reaches the Database

Rate limiting controls how often someone can call an endpoint. It says nothing about what they send. Every route handler and Server Action still needs its own validation, same principle covered in the form handling setup, applied specifically at the API boundary here.

// app/api/users/route.ts
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
});

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = CreateUserSchema.safeParse(body);

  if (!parsed.success) {
    return Response.json({ error: 'Invalid input' }, { status: 400 });
  }

  // proceed with parsed.data, never the raw body
}
Enter fullscreen mode Exit fullscreen mode

Never pass the raw request body into a database call. Even with rate limiting in place, an endpoint accepting arbitrary shaped input is still open to malformed data, oversized payloads, or fields that were never meant to be settable from outside.


6. CORS for Public API Routes

If an API route is meant to be called from your own frontend only, restricting CORS prevents other websites from calling it directly from a browser using a visitor's already-authenticated session.

// app/api/data/route.ts
export async function GET(request: Request) {
  const origin = request.headers.get('origin');
  const allowedOrigin = process.env.NEXT_PUBLIC_URL;

  const response = Response.json({ data: 'example' });

  if (origin === allowedOrigin) {
    response.headers.set('Access-Control-Allow-Origin', allowedOrigin);
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

This matters specifically for cookie-based auth. Without a CORS restriction, a malicious site could make a request that rides on a logged-in user's cookies, and without a check like this, your API would happily respond as if the request came from your own frontend.


7. Secrets Never Reach the Client

An easy mistake in Next.js specifically, any environment variable prefixed with NEXT_PUBLIC_ gets bundled into client-side JavaScript, visible to anyone who opens dev tools.

// โŒ This leaks the secret into the browser bundle
const apiKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;

// โœ… Secret keys stay server-only, no NEXT_PUBLIC_ prefix
const apiKey = process.env.STRIPE_SECRET_KEY;
Enter fullscreen mode Exit fullscreen mode

Only prefix an environment variable with NEXT_PUBLIC_ when the value is genuinely meant to be public, a publishable Stripe key, an analytics ID, not an API secret, database URL, or anything meant to stay server-side only.


Summary

Pattern Protects against
Sliding window rate limit (Upstash) Endpoint abuse, accidental retry loops, brute force attempts
Per-user rate limiting on authenticated actions IP rotation and shared-IP inaccuracy
Zod validation at the API boundary Malformed or unexpected input reaching the database
CORS restriction on cookie-authenticated routes Cross-site requests riding on a logged-in session
No NEXT_PUBLIC_ on secrets API keys and secrets leaking into the client bundle

Rate limiting and validation solve different problems and both matter. Rate limiting controls how often, validation controls what. An endpoint with one but not the other is still exposed, just in a different way.

I put this exact set of protections, rate limiting through Upstash, Zod validation at every boundary, on any public-facing endpoint across the SaaS projects I build.

Get the templates: https://pixelanas.gumroad.com

Have you had an endpoint get hit hard enough to actually need rate limiting in production? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)