· 3 min read · Security, APIs, Next.js, Laravel

Rate limiting APIs and contact forms, with Next.js and Laravel examples

Why every public endpoint needs a rate limit, how fixed window, sliding window and token bucket algorithms differ, and how to add limits in Next.js and Laravel.

Put a contact form online and within days a bot will find it. Publish an API and someone will call it in a loop. Without a rate limit, one script can fill your inbox, run up your email provider's bill, or slow the site down for everyone else. A rate limit caps how often one client can call an endpoint, and it's one of the cheapest protections you can add.

What to limit, and by what

First decide who counts as "one client":

  • By IP address for anonymous endpoints like contact forms, sign-up and login.
  • By user ID or API key for authenticated APIs, so people behind the same office network don't block each other.
  • By target for login and password reset: limit attempts per email address as well as per IP, so one account can't be brute-forced from many IPs.

Three common algorithms

Fixed window

Count requests per client in fixed blocks of time, like "5 per minute", and reset the counter at the start of each minute. It's simple and cheap. The weakness is the boundary: a client can send 5 requests at 12:00:59 and 5 more at 12:01:00, getting 10 in two seconds.

Sliding window

Count requests in the last 60 seconds from right now, not since the start of the minute. This removes the boundary burst. A common, cheap approximation weights the previous window's count by how much of it still overlaps.

Token bucket

Each client has a bucket that holds, say, 10 tokens and refills at 1 token per second. Each request takes a token; an empty bucket means the request is refused. This allows short bursts, which feels natural for real users, while keeping the long-run rate fixed. Most API gateways use a version of it.

Next.js: a limit on a route handler

On serverless hosting, each request may run on a different instance, so an in-memory counter isn't reliable. A shared store like Redis (for example Upstash) keeps the count in one place:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "10 m"), // 5 messages per 10 minutes
});

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  const { success, reset } = await limiter.limit(`contact:${ip}`);

  if (!success) {
    return Response.json(
      { error: "Too many messages. Please try again later." },
      { status: 429, headers: { "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)) } }
    );
  }

  // validate and send the message...
}

Laravel: built in

Laravel ships with a rate limiter. Define a named limit once and attach it to routes:

// app/Providers/AppServiceProvider.php
RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(30)->by($request->ip());
});

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->ip()),
        Limit::perMinute(5)->by(strtolower($request->input('email'))),
    ];
});

// routes/api.php
Route::middleware('throttle:api')->group(function () { /* ... */ });

With a Redis cache driver, the counts are shared across every server and queue worker.

Respond properly

  • Return status 429 Too Many Requests, not a generic error.
  • Include a Retry-After header so well-behaved clients know when to try again.
  • Show people a clear, friendly message. Real users hit limits too, usually by double-clicking.
  • Log limit hits. A sudden spike is often the first sign of an attack.

Rate limits are one layer

A rate limit slows abuse down but doesn't stop a patient attacker with many IPs. For public forms, pair it with a hidden honeypot field, server-side validation and, if spam persists, a CAPTCHA such as Cloudflare Turnstile. For APIs, pair it with authentication and permission checks on every request.

Checklist

  • Limit every public endpoint, especially forms, login and password reset
  • Key limits by IP for anonymous users and by user or key when signed in
  • Use a shared store like Redis on serverless or multi-server setups
  • Prefer sliding window or token bucket over a plain fixed window
  • Return 429 with Retry-After
  • Combine with validation, honeypots and authentication

Getting spam or abuse on a form or API? Let's fix it.

Keep reading