Next.js
Rate Limit a Public Next.js Route with a Sliding Window
Count requests per visitor in a one-minute window, return 429 when the window is full, and store the count where every server can see it.
- Next.js
- API
- Security
- PostgreSQL
On this page
A public form or API route will be called by people, by browsers that retry, and by scripts that do not read your about page. A rate limit gives each visitor a small budget, then asks them to wait. The useful version counts requests in a shared store, returns HTTP 429 when the budget is spent, and tells the client when it may try again.
A sliding window is easy to explain. Keep the timestamps of recent hits for a key. A hit is allowed when fewer than N of those timestamps fall inside the last minute. Old timestamps fall out of the window on their own, so a visitor who waited is not punished for traffic from an hour ago.
An in-memory window for one Node process
On a single long-running server, a Map is enough to learn the shape. The key is something stable for the visitor, such as the route plus the client address. Trim stamps older than the window, refuse the call when the list is already at the limit, otherwise record now.
const hits = new Map<string, number[]>();
export function allow(key: string, limit = 5, windowMs = 60_000) {
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((stamp) => now - stamp < windowMs);
if (recent.length >= limit) {
hits.set(key, recent);
return false;
}
recent.push(now);
hits.set(key, recent);
return true;
}
That Map lives in one process. A platform that starts many short-lived server instances, including Vercel, gives each instance its own Map. A script that spreads calls across instances will see a fresh budget every time. Use the memory version in local development and on a single Node server. For a public production route, store the stamps where every instance reads the same rows.
Store the window in Postgres
A small table of events is enough. The bucket is a string you choose, such as contact: plus a client key. Count the rows newer than one minute. If the count is under the limit, insert one row and continue. If it is not, return 429 and skip the insert so a refusal does not extend the lockout by itself. You can delete rows older than a day from a scheduled query so the table stays small.
create table rate_events (
id bigint generated always as identity primary key,
bucket text not null,
created_at timestamptz not null default now()
);
create index rate_events_bucket_created_idx
on rate_events (bucket, created_at desc);
select count(*)::int as hits
from rate_events
where bucket = $1
and created_at > now() - interval '1 minute';
Answer 429 from the route handler
Read the client address from the platform header, take the first value, and build the bucket. When the window is full, respond with JSON and a Retry-After header set to the window in seconds. Do the limit check before you send email, call a model, or write the visitor's message. The expensive work is what you are protecting.
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local";
const allowed = await allowInDatabase(`contact:${ip}`);
if (!allowed) {
return Response.json(
{ error: "Too many messages. Try again in a minute." },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
return Response.json({ ok: true });
}
- Two requests can both read a count under the limit and both insert. For a contact form, that race is acceptable. A stricter counter uses a single SQL statement that inserts only when the count is still under the limit.
- Many people can share one address through a network. A limit of five per minute is polite for a form and harsh if you set it to one.
- The address header is a hint supplied by your host. Use the header your platform documents, and keep secrets out of the bucket name.
- Log the 429 without logging the message body. You want to see abuse, and you do not want a second copy of someone's private note.
