Rate-limiting Server Actions in Next.js

Erfan EbrahimniaErfan Ebrahimnia

It's easy to look at Next.js Server Actions and forget that they are, effectively, public API endpoints. The "magic" abstraction layer often tricks us into skipping standard backend best practices like validation and security.

But under the hood, Server Actions are still triggered by standard HTTP requests, meaning they are just as vulnerable to spam and abuse as any other route. If you have an action that is computationally expensive or costs money (like an AI call), you need to protect it.

Here is how to properly rate-limit your Server Actions to keep your application resilient.


Rate-limiting based on User ID

Rate-limiting requires a unique identifier for the incoming request. If you are dealing with authenticated users, this is straightforward: use their database ID.

Here's how you might handle this in a standard API route:

// API route (simplified):
export const POST = async () => {
  const user = await auth();

  // "rateLimiter" is a placeholder for your library of choice (e.g., Upstash, Redis)
  const { success } = await rateLimiter.limit(`subscription:${user.id}`);

  if (!success) {
    return new Response("Rate limited", { status: 429 });
  }

  return Response.json(await someExpensiveOperation());
};

When you move to Server Actions, the logic remains almost identical. The main difference is that instead of returning a 429 status code, you return a structured error object.

// Server Action (simplified):
export const action = async () => {
  const user = await auth();

  const { success } = await rateLimiter.limit(`subscription:${user.id}`);

  if (!success) {
    return {
      error: "You are doing that too much. Please try again later.",
    };
  }

  return someExpensiveOperation();
};

Rate-limiting based on IP address

Things get trickier when you need to rate-limit anonymous users (e.g., a public waitlist form or a "contact us" message). Since there is no user ID, the only reliable identifier is the IP address.

In a standard API route, you have direct access to the Request object, that you can use to extract the IP address from the request headers.

// API route:
export const POST = async (req: NextApiRequest) => {
  const ip = getIP(req);

  const { success } = await rateLimiter.limit(`subscription:${ip}`);

  if (!success) {
    return new Response("Rate limited", { status: 429 });
  }

  return Response.json(await someExpensiveOperation());
};

For context, getIP is a small helper that looks at the x-forwarded-for header:

export function getIP(request: Request) {
  const xff = request.headers.get("x-forwarded-for");
  return xff ? xff.split(",")[0] : "127.0.0.1";
}

The Server Action problem

Let's try the same in a Server Action. Just extract the IP address from the request head... hold on, we don't have a request object in a Server Action.

By design, Next.js Server Actions do not expose the raw Request object. However, we can work around this using the headers() function provided by next/headers. This gives us read-only access to the request headers associated with the current incoming request.

export const action = async () => {
  const headerList = await headers();
  const ip = (headerList.get("x-forwarded-for") ?? "127.0.0.1").split(",")[0];

  const { success } = await rateLimiter.limit(`subscription:${ip}`);

  if (!success) {
    return {
      error: "Too many requests.",
    };
  }

  return someExpensiveOperation();
};

By accessing the IP address through the headers() function, we effectively work around the limitation of not having a request object in Server Actions and can apply rate-limiting just as we would in a standard API route.

Make sure to stay up to date about new features, best articles and tools in the Next.js ecosystem by subscribing to the newsletter.

Once‑weekly email, best links, no fluff.

Join 6,000+ developers. 100% free.