What you need and what you will have at the end
You need a Next.js app using the App Router, a free Upstash Redis database from the Upstash console and Node with npm. Serverless functions do not share memory between invocations, which is why a plain object counter fails and a shared Redis store is needed.
At the end each sensitive route will refuse excess requests with a 429 status and tell the client when to retry.
Step 1: Decide limits per route type
Set limits by what an attacker gains and what a request costs you. The numbers below are starting points to tune against your real traffic, not standards. Count login attempts per account and per IP separately, because one attacker can spread guesses across many accounts and many attackers can aim at one account.
| Route type | Key | Suggested window | Why |
|---|---|---|---|
| Login | IP and account | 5 attempts per minute per pair, wider per IP | Slows password guessing |
| OTP send | Phone or email, and IP | 3 per 10 minutes per recipient | Stops SMS and email cost abuse |
| OTP verify | Account | 5 per 10 minutes | Six-digit codes are guessable if unlimited |
| Paid or LLM API | User id | Per minute burst plus per day | Caps provider bill |
| General API | IP | 60 per minute | Baseline scraping guard |
Step 2: Install and configure Upstash
Install the two packages. In the Upstash console, create a Redis database and copy its REST URL and token into your environment. The client reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN when you call Redis.fromEnv(). Add the same variables in your hosting provider settings, as server-side variables without any public prefix.
npm install @upstash/ratelimit @upstash/redis
UPSTASH_REDIS_REST_URL=your-rest-url
UPSTASH_REDIS_REST_TOKEN=your-rest-tokenStep 3: Build one helper
Create one file that defines a limiter for each route type and a function that applies it and builds the 429 response. The sliding window algorithm smooths out bursts at window edges. The limit call returns success, limit, remaining and reset, where reset is a Unix timestamp in milliseconds for when the window frees up. The helper converts it to seconds for Retry-After, which the HTTP specification defines as either a date or a number of seconds.
Take the client address from the header your hosting platform sets, and confirm which header that is for your host. A forwarded header the client can set freely is spoofable, so use the value your platform's edge writes. Fall back to a fixed label so a missing header cannot skip the limit.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export const limiters = {
login: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"),
prefix: "rl:login",
}),
otpSend: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, "10 m"),
prefix: "rl:otp-send",
}),
paid: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "1 m"),
prefix: "rl:paid",
}),
};
export function clientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
return forwarded ? forwarded.split(",")[0].trim() : "unknown";
}
export async function enforce(
limiter: Ratelimit,
key: string
): Promise<Response | null> {
const { success, reset } = await limiter.limit(key);
if (success) return null;
const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
return Response.json(
{ error: "Too many requests" },
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}Step 4: Apply it in a route handler
Call the helper at the top of the handler, before any expensive work such as a database query, password hash or provider call. For login, key on IP plus the submitted email so one pair is throttled, and add a second, looser check on the IP alone so guessing across many emails is also slowed. For authenticated routes, key on the user id from the verified session.
Normalize the account part of the key, for example by lowercasing an email, so that variations do not get separate counters.
import { limiters, clientIp, enforce } from "@/lib/rate-limit";
export async function POST(request: Request) {
const body = await request.json();
const email = String(body.email ?? "").trim().toLowerCase();
const ip = clientIp(request);
const blocked =
(await enforce(limiters.login, ip + ":" + email)) ??
(await enforce(limiters.login, "ip:" + ip));
if (blocked) return blocked;
return Response.json({ ok: true });
}Step 5: Test with a curl loop on your own app
Run the app locally or on staging and send more requests than the limit within the window. The first five should return 200 and later ones 429. Print only the status codes, then look at one blocked response in full to confirm the header. Only run this against your own app.
for i in $(seq 1 8); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"x"}'
done
curl -i -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"x"}'Common mistakes
How to verify
The pass condition is that the curl loop shows 200 up to your limit and 429 afterward, the blocked response carries a Retry-After value in seconds, and after that many seconds a request succeeds again. Check the Upstash data browser to confirm keys with your prefixes appear. Then confirm the limit applies on the deployed app and not only locally.
Keep it working
Add a limiter to every new route that sends messages, checks credentials or calls a paid provider. Review real 429 counts after launch and adjust limits, and rotate the Upstash token if it is ever exposed. For traffic-level abuse, also consider rate limiting rules at your CDN or firewall in front of the app.
Frequently asked questions
Why not use a simple in-memory counter?
Serverless and multi-instance deployments do not share memory. Each instance would keep its own counter, and counters reset when instances restart, so an attacker would effectively get many times the limit. A shared store such as Redis keeps one count across all instances.
What should the Retry-After header contain?
Either an HTTP date or a whole number of seconds to wait, according to the HTTP specification. The helper in this guide sends seconds. Support among clients is uneven, so the JSON body should also make the situation clear to users.
Should the limiter fail open or closed if Redis is down?
Decide per route. Fail closed on login and OTP routes, where an outage would otherwise remove your protection, and consider failing open on low-risk read routes. The Upstash library also has a timeout option that lets requests pass if the check is too slow, so set it deliberately.
Is IP-based limiting enough?
No. Attackers rotate addresses, and shared networks put many legitimate users behind one. Combine IP with the account or user id, and add limits where the cost sits, such as per-recipient limits on OTP sends.