All articles
Web DevelopmentSeptember 18, 2026 6 min read

Server Actions Under Load: What We Learned Rate-Limiting Them at the Edge

Server Actions look like plain function calls, but every one is a POST to your origin. Here's what happened when ours got hammered — and the edge rate-limiting pattern we now ship by default.

Server Actions feel like local function calls, and that's exactly the problem. Every invocation is an unauthenticated-by-default POST to your Next.js origin, and the ergonomics are so good that a form or a button click can turn into a denial-of-wallet event before you notice. We learned this the hard way on a client's launch week, and the fix landed in our default template within 48 hours.

This is the pattern we now use to rate-limit Server Actions at the edge, the mistakes we made along the way, and why the obvious solutions don't quite work.

The incident, briefly

A marketing site we'd rebuilt on the App Router had a newsletter form wired to a Server Action. The action did three things: validated the email, called a third-party ESP, and wrote an audit row to Postgres. Nothing exotic.

On launch morning a scraper found the page and started replaying the form POST. Within 40 minutes we'd burned through a chunk of the ESP's daily quota, our Postgres connection pool was thrashing, and TTFB on unrelated pages had crept up because the Node runtime was busy handling action retries.

The function itself was fine. The problem was that nothing between the internet and that function knew how to say "enough".

Why the usual answers don't fit

If you've done this on a REST or tRPC API, you probably reach for a middleware that inspects the route and applies a token bucket keyed by IP. Server Actions break that model in two annoying ways.

First, all Server Actions POST to the same URL — usually the page they were rendered on. You can't route-match your way to a per-action limit. A deleteAccount action and a likePost action look identical to middleware unless you dig deeper.

Second, the action identifier lives in a header, not the path. Next.js sends a Next-Action header containing an opaque hashed ID that maps to the specific server function. That ID is stable per deployment but changes when the action's code changes, which has real consequences for how you configure limits.

Third, and this catches teams out: middleware runs on the edge runtime, but your Server Action runs wherever the page's runtime is set. If you try to share a limiter instance between them, you'll be debugging cold-start races for a week.

The pattern: limit at the edge, verify in the action

We do two layers.

  1. Edge middleware applies a coarse limit on any POST that carries a Next-Action header. This is the cheap outer wall — it stops floods before they hit your Node runtime, your DB pool, or your paid third-party APIs.
  2. The action itself applies a fine-grained limit keyed by user ID (or session) for anything sensitive. This is the inner wall that catches authenticated abuse the edge can't see.

Here's the middleware, using Upstash's edge-compatible Redis client. Any KV store with an HTTP API works; we've also used Cloudflare KV and Vercel KV.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, '1 m'),
  analytics: true,
  prefix: 'rl:action:edge',
});

export async function middleware(req: NextRequest) {
  const actionId = req.headers.get('next-action');
  if (!actionId) return NextResponse.next();

  const ip =
    req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
    '127.0.0.1';

  const key = `${ip}:${actionId}`;
  const { success, limit, remaining, reset } = await ratelimit.limit(key);

  if (!success) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
      },
    });
  }

  return NextResponse.next();
}

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

A few things worth flagging.

Key by action ID, not just IP

Keying by IP alone means a legitimate user clicking three different buttons on a page shares a bucket with an attacker spamming one endpoint. Combining IP and the Next-Action header gives you per-endpoint fairness without ever having to name your actions in middleware.

x-forwarded-for is only as trustworthy as your platform

On Vercel, Netlify, and Cloudflare the leftmost IP is set by the platform and safe to use. If you're self-hosting behind an arbitrary proxy, don't trust it — read the platform-specific header (cf-connecting-ip, fly-client-ip, etc.) or you'll rate-limit everyone as 127.0.0.1.

The action ID changes on deploy

If your rate-limit keys embed the action ID and you deploy a change to that action's file, the old and new IDs both count as valid for a brief window (users on the old page will still send the old ID). Buckets are separate, which is usually fine, but don't build alerting that assumes ID stability across deploys.

The inner wall: limiting by user, not IP

Once a request survives the edge, the action can apply a stricter limit keyed by whatever identity you actually care about. IP-based limits are useless behind a corporate NAT or a mobile carrier.

// app/actions/subscribe.ts
'use server';

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { auth } from '@/lib/auth';
import { z } from 'zod';

const userLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.tokenBucket(5, '1 h', 5),
  prefix: 'rl:action:subscribe',
});

const Input = z.object({ email: z.string().email() });

export async function subscribe(formData: FormData) {
  const session = await auth();
  const identity = session?.user?.id ?? `anon:${await fingerprint()}`;

  const { success, reset } = await userLimit.limit(identity);
  if (!success) {
    return {
      ok: false as const,
      error: 'You have hit the subscribe limit. Try again shortly.',
      retryAt: reset,
    };
  }

  const parsed = Input.safeParse({ email: formData.get('email') });
  if (!parsed.success) {
    return { ok: false as const, error: 'Invalid email' };
  }

  // ...ESP call and DB write
  return { ok: true as const };
}

async function fingerprint() {
  // A stable-ish anon id, e.g. hash of session cookie + user-agent.
  // Not for security, just for bucketing anonymous traffic.
  return 'derived-anon-id';
}

Two details that matter more than they look.

Returning a structured error object instead of throwing lets useActionState on the client render a real message. Throwing from a Server Action gives your users the generic Next.js error boundary, which is exactly what a bot wants — no signal that it's been blocked.

Using a token bucket for user-facing limits (as opposed to a sliding window at the edge) means a real human who clicks twice in quick succession isn't punished, but sustained abuse still gets throttled.

Gotchas we hit in production

Prefetches and RSC payloads look like actions if you squint

Early on we matched too broadly and started 429-ing legitimate RSC navigations. The fix is strict: only rate-limit requests that actually have a Next-Action header. Don't try to be clever with Content-Type or method alone.

Middleware timeouts are real

On most edge platforms your middleware has a hard budget (often 50–100ms for compute, longer with I/O but not much). A slow KV call blocks every request. Use a client with connection pooling, keep the round trip in-region, and add a short timeout with a fail-open fallback. Fail open, not closed — a broken limiter should not take down your site.

const { success } = await Promise.race([
  ratelimit.limit(key),
  new Promise<{ success: true }>((r) =>
    setTimeout(() => r({ success: true }), 150),
  ),
]);

Preview deployments will trash your quotas

If every PR preview shares production Redis, one dev running a load test will exhaust the free tier. Namespace the prefix with process.env.VERCEL_ENV or equivalent, and point previews at a separate KV instance.

CSRF is not solved by rate limiting

Next.js's Server Actions do check the Origin header against the host by default, which handles the common case. But if you've relaxed allowedOrigins in next.config.js for a legitimate reason, rate limits are not a substitute for proper CSRF handling. Keep both.

Where we'd start

If you're shipping Server Actions to real users, add the edge middleware limiter first — it's twenty lines and it protects everything, including actions you haven't written yet. Then, for any action that touches a paid API, a database write, or an email/SMS send, add the per-user inner limit and return a structured error your form can render.

Don't wait for the incident. The nice thing about this pattern is that the day you actually need it, you won't be awake to write it. If you want a hand designing this into a larger platform, that's the kind of work our web development team does day in, day out.

#Next.js#React 19#Server Actions#Edge#Performance#Security

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project