Next.js API Route Tutorial: Validate, Rate Limit, Version
A Next.js API route tutorial from a live site: validate input, rate limit per client, return one JSON error shape, version under /api/v1 and send email.
By Matthews WongReviewed on
Key takeaway
A Route Handler is a route.ts file that answers HTTP requests with the Web Request and Response APIs. A safe one does five jobs in order: limit how often a client may call it, parse and validate the input, keep secrets on the server, answer every failure in one predictable JSON shape, and live under a versioned path that can change without breaking callers.
You will be able to
- Write a POST handler that rejects bad input and floods before doing any work.
- Return every error as one JSON envelope with a stable code and the right status.
- Version an API and retire an old path with Deprecation and Sunset headers.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
One contact request through a safe route
A POST to the v1 path reaches the shared handler, which counts the client first, validates the body, calls the email service from the server, and answers any failure in the same JSON shape.
Keys: ← → step · Home / End · K play or pause
A form posts to a versioned path. Behind it sit three guards in order: a rate limit, validation and a server-side call. Any of them can end the request with an error.
The v1 file only re-exports the handler. The old unversioned path keeps working as a deprecated alias, so there is one implementation behind both URLs.
re-export POST from the contact routeBefore reading the body, the handler counts this client in a fixed window. The key joins the route name and the client address. Cheap checks come before expensive work.
checkRateLimit(contact + client IP, limit 5, window 60 seconds)Predict the next step A client sends a sixth request inside the same minute. What does it get? (429 with a Retry-After header · 200, but the email is skipped · 500, the server is overloaded)The sixth request never reaches validation. It gets status 429, a Retry-After header, and the same JSON envelope as every other error, with a hint on when to retry.
rateLimited(limit, resetIn)Under the limit, the body is parsed and every field is trimmed and capped. Bad JSON or a missing field ends here with a 400 and a hint naming the field.
typeof v === string ? v.trim().slice(0, max) : emptyThe key comes from an environment variable, read only on the server, so the browser never holds it. The route calls the email service and checks the error the SDK returns.
const error = await emails.send(from, to, replyTo, template)Predict the next step The email service rejects the message. Which status does this route return? (400, the client sent a bad request · 502, the upstream call failed · 200, the form was valid)A rejected send becomes 502 upstream_error with a generic message. The provider's own error is logged on the server and never shown to the visitor.
console.error(provider error) return apiError(status 502)
What is a Route Handler, and how does it differ from a page?
A Route Handler is a route.ts file inside the app directory that exports one function per HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS. It uses the standard Web Request and Response APIs, plus the NextRequest and NextResponse helpers Next.js adds. If a method you did not export is called, Next.js answers 405 Method Not Allowed. A route.ts cannot sit at the same segment as a page.tsx, so an API usually lives under a folder such as api.
Read more (2)Show less
Route Handlers are not cached by default. You can opt a GET handler into caching, but the other methods are never cached, even when they sit in the same file as a cached GET. That is why a POST that sends email is safe to write: it runs on every request. The runtime defaults to nodejs, and the older edge value is marked deprecated in the segment config docs.
Secrets belong in environment variables read from process.env inside the handler. By default they exist only on the server. A variable prefixed with NEXT_PUBLIC_ is the opposite: Next.js inlines its value into the JavaScript sent to the browser at build time, so a secret must never carry that prefix. Next.js also expects .env files to stay out of version control.
How do you validate input in a Next.js API route?
Treat the body as untrusted text. The contact route parses it inside a try and catch and answers a 400 with code invalid_json if parsing fails, instead of letting an exception turn into a 500. Then it coerces every field: a value that is not a string becomes an empty string, and a string is trimmed and cut to a fixed maximum. Only after that does it check the rules, such as a name of at least two characters, an email that matches a simple pattern, and a message of at least ten.
Read more (2)Show less
Each failure names what to fix. The hint says which field, and which range it must fall in, so a caller can repair the request without reading the source. The route also carries a honeypot field: real users leave it empty, so a filled value is treated as a bot and answered with a quiet success without sending anything. That keeps scripts from learning which of their requests were noticed.
Validation sits after the rate limit on purpose. A check that costs nothing, a counter in memory, should run before work that costs something, and a flood of malformed requests should hit the cheapest wall first.
How should rate limiting work, and what should a 429 say?
MDN defines 429 Too Many Requests as the client sending too many requests in a given amount of time, and says a Retry-After header may tell the client how long to wait. The site's limiter is a Map in the server process: each key holds a count and a reset time, a request inside the window increments the count, and when the count reaches the limit the call returns how many seconds remain. The contact route allows five requests a minute per client.
Read more (2)Show less
An in-memory limiter is honest but small. Its own file comment says that in production you should consider Redis for distributed limiting, because each server process keeps its own Map: the counts reset on restart, and two instances would each allow the full limit. It is a good first wall for one container, and the wrong tool for a fleet.
The key matters as much as the counter. This site once keyed on the first X-Forwarded-For entry, which is whatever the client chose to send, so anyone could mint a fresh bucket per request. The fix reads the proxy's own header first and otherwise the last forwarded entry, the one the site's proxy wrote. A rate limit that trusts a spoofable header limits nobody.
What is a consistent JSON error envelope, and why use one?
An envelope is one shape every failure shares. Here it is success set to false, a human-readable error, a stable machine-readable code, a hint on how to recover, a docs link and, for rate limits, retryAfter in seconds. A caller then branches on code instead of parsing prose, and a person reading the response still gets a sentence they can act on. One helper builds every error, so no route can answer in its own shape by accident.
Read more (2)Show less
Status codes carry the category and the envelope's code carries the detail. A 405 must include an Allow header listing the methods the resource supports, as MDN requires of servers, and the helper for unsupported methods adds it. A 502 means a server acting as a gateway received an invalid response from upstream; this route returns it when the email provider rejects a send, with a generic message, while the provider's own error goes to the server log only.
Send Cache-Control no-store on errors, as this site's helper does, so they are not stored, and let one place define the list of codes so a client, a test and the API description can all check against it.
How do you version an API and retire an old path safely?
Put the version in the path. Here the current endpoints live under /api/v1/, and the file at app/api/v1/contact/route.ts does nothing but re-export the handler from app/api/contact. The old unversioned path stays as a deprecated alias, so cached pages and old clients keep working while new ones move to v1. The versioning policy is one constant: within a version changes stay backwards compatible, a breaking change ships as /api/v2/, and the old version keeps working for a stated period.
Read more (1)Show less
Tell callers with headers, not just docs. RFC 9745 defines a Deprecation response header whose value is a structured-field date, written with an at sign and Unix seconds, such as @1688169599, and a link relation named deprecation that points to human-readable documentation. RFC 8594 defines Sunset, an HTTP-date after which the resource is likely to become unresponsive. Deprecation says it is old; Sunset says when it goes. This site stamps the aliases through next.config headers and adds Sunset only once a removal date is set.
How do you call an email service safely from a route?
Call it from the server, never the browser, because the call needs a secret. Resend's send endpoint takes a bearer API key, and its Node SDK returns an object with data and error rather than throwing, so the route checks error and maps it to a response. A template can be sent by passing its id and a map of variables; Resend rejects a request that mixes a template with html, text or react content. Setting reply_to to the visitor's address lets the owner answer straight from the inbox.
Read more (1)Show less
Three habits keep the route calm. Create the client lazily and reuse it. Check that the key exists before doing anything and answer service_unavailable if it is missing, rather than crashing. And log the provider's error on the server while returning only a generic message, so a visitor never sees provider details.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
export async function POST(request: NextRequest) {
const clientIp = getClientIp(request);
const rateLimit = checkRateLimit(`contact:${clientIp}`, {
limit: RATE_LIMIT,
window: 60,
});
if (!rateLimit.success) {
return rateLimited(RATE_LIMIT, rateLimit.resetIn);
}
let body: ContactBody;
try {
body = await request.json();
} catch {
return apiError({
status: 400,
code: "invalid_json",
message: "Invalid JSON",
hint: "Send a JSON object as the body, with Content-Type: application/json.",
});
}The first two guards. The limiter runs before the body is read, and its key is the route name joined to the client address. The JSON parse sits in a try and catch so a broken body is a 400 with code invalid_json, not an unhandled exception.
const clamp = (v: unknown, max: number): string =>
typeof v === "string" ? v.trim().slice(0, max) : "";
const name = clamp(body.name, 100);
const email = clamp(body.email, 200);
const intent = body.intent === "hr" ? "hr" : "project";
const company = clamp(body.company, 120);
const role = clamp(body.role, 120);
const projectType = clamp(body.projectType, 80);
const timeline = clamp(body.timeline, 80);
const message = clamp(body.message, 5000);
if (name.length < 2) {
return apiError({
status: 400,
code: "invalid_request",
message: "Please provide a valid name.",
hint: "`name` must be 2 to 100 characters.",
});
}
if (!EMAIL_RE.test(email)) {
return apiError({
status: 400,
code: "invalid_request",
message: "Please provide a valid email.",
hint: "`email` must be a valid email address of at most 200 characters.",
});
}Coerce, then check. clamp turns anything that is not a string into an empty string and cuts the rest to a maximum length, so the checks below only ever see short strings. Each hint names the field and its allowed range.
try {
const { error } = await getResend(apiKey).emails.send({
from: fromEmail,
to: toEmail,
replyTo: email,
template: {
id: isHiring ? TEMPLATE_HIRING : TEMPLATE_PROJECT,
variables,
},
});
if (error) {
console.error("Resend send error:", error);
return apiError({
status: 502,
code: "upstream_error",
message: "Failed to send message.",
hint: "The email provider rejected the message. Retry later, or email matthewswong2610@gmail.com directly.",
});
}
return NextResponse.json({ success: true });The server-side call. The SDK returns an error instead of throwing, so the route checks it, logs the provider's detail on the server and answers 502 upstream_error with a generic message. A template id and variables replace an HTML body.
export function rateLimited(limit: number, resetIn: number): Response {
return apiError({
status: 429,
code: "rate_limited",
message: "Too many requests. Please try again later.",
hint: `Wait ${resetIn} seconds before retrying; the limit is ${limit} requests per minute per client.`,
retryAfter: resetIn,
headers: {
"Retry-After": String(resetIn),
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(resetIn),
},
});
}One helper builds the 429. Retry-After carries the wait in seconds, the X-RateLimit headers describe the window, and retryAfter repeats it in the body for clients that only read JSON. The hint tells a person what to do.
// Version 1 of the contact endpoint. The handler lives at app/api/contact,
// which stays as a deprecated, unversioned alias (see next.config.ts).
export { POST, GET, PUT, PATCH, DELETE } from "@/app/api/contact/route";
// Resend needs the Node.js runtime; segment config is never re-exported.
export const runtime = "nodejs";The whole v1 endpoint. It re-exports every method from the original handler, so v1 and the deprecated alias can never diverge. It still declares the runtime itself; its own comment says segment config is never re-exported.
export const DEPRECATED_API_ALIASES = [
{ path: "/api/chat", successor: API_PATHS.chat },
{ path: "/api/chat/followups", successor: API_PATHS.followups },
{ path: "/api/contact", successor: API_PATHS.contact },
] as const;The old paths as data. next.config.ts reads this list to stamp each alias with Deprecation and Link headers, and the developer guide and the OpenAPI description read the same list, so a deprecated path is declared once.
// Fall back to the LAST X-Forwarded-For entry, not the first. Both Cloudflare
// and nginx's $proxy_add_x_forwarded_for append, so the leftmost entry is
// whatever the client chose to send: reading it let anyone mint a fresh
// rate-limit bucket per request with `X-Forwarded-For: 1.2.3.<random>`, which
// defeated every limit here — including the Groq spend and the Resend send
// path. The rightmost entry is the one our own proxy wrote.
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const hops = forwarded
.split(",")
.map((hop) => hop.trim())
.filter(Boolean);
if (hops.length > 0) {
return hops[hops.length - 1];
}
}Which address is the client? The leftmost forwarded entry is whatever the caller sent, so the code reads the last one, written by the site's own proxy. The comment records the bug this replaced.
Mistakes that cost real time
A rate limit keyed on a spoofable header
Keying on the leftmost X-Forwarded-For value let anyone send a different fake address with every request and get a fresh bucket each time. Key on a header your own edge sets, or on the entry your own proxy appended.
An in-memory limiter that resets or splits
The counts live in one process. A restart empties them, and two instances each enforce the whole limit. Fine for one container; use a shared store such as Redis when the API runs on several.
A secret behind NEXT_PUBLIC_
Naming the email key with that prefix inlines its value into the browser bundle at build time. The server could already read it without the prefix. Keep secrets unprefixed and read them only in the route.
Leaking the provider's error to the visitor
Returning the SDK's error object tells a stranger which vendor you use and what it rejected. Log it on the server and answer with your own code and a generic message, as the 502 path does.
What I'd change here: a quiet fallback recipient
If the recipient variable is unset, the route falls back to a hard-coded default address instead of failing. A missing setting then looks like it works. The key is treated more strictly: a missing key answers service_unavailable. I would make the recipient fail the same way.
| Code | Status | When it happens |
|---|---|---|
| invalid_json | 400 | The body is not parseable JSON |
| invalid_request | 400 | A field fails its rule |
| rate_limited | 429 | The client passed its window limit |
| method_not_allowed | 405 | A method the route does not implement |
| service_unavailable | 500 | The email key is not configured |
| upstream_error | 502 | The email provider rejected the send |
Do it on your own machine
You need a Next.js 15 project and curl. Build one POST route with a limiter, validation and an error helper, then break it on purpose.
Write the error helper first. One function builds every failure, with a stable code and a hint.
export function apiError(status: number, code: string, message: string, hint: string, headers: Record<string, string> = {}) { return Response.json( { success: false, error: message, code, hint }, { status, headers: { "Cache-Control": "no-store", ...headers } }, ); }Add a tiny in-memory limiter: a Map from key to count and reset time.
const hits = new Map<string, { count: number; resetAt: number }>(); export function check(key: string, limit = 5, windowMs = 60_000) { const now = Date.now(); const hit = hits.get(key); if (!hit || now > hit.resetAt) { hits.set(key, { count: 1, resetAt: now + windowMs }); return { ok: true, retryAfter: 0 }; } if (hit.count >= limit) return { ok: false, retryAfter: Math.ceil((hit.resetAt - now) / 1000) }; hit.count += 1; return { ok: true, retryAfter: 0 }; }Create app/api/v1/echo/route.ts. Limit first, then parse in a try and catch, then clamp and check the fields.
export async function POST(request: Request) { const ip = request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim() ?? "unknown"; const limit = check(`echo:${ip}`); if (!limit.ok) return apiError(429, "rate_limited", "Too many requests.", `Wait ${limit.retryAfter} seconds.`, { "Retry-After": String(limit.retryAfter) }); let body: { name?: unknown }; try { body = await request.json(); } catch { return apiError(400, "invalid_json", "Invalid JSON", "Send a JSON object."); } const name = typeof body.name === "string" ? body.name.trim().slice(0, 50) : ""; if (name.length < 2) return apiError(400, "invalid_request", "Invalid name.", "name must be 2 to 50 characters."); return Response.json({ success: true, name }); }Hit it seven times in a row and read the status and headers. The first five should return 200, the sixth and seventh 429 with Retry-After.
for i in 1 2 3 4 5 6 7; do curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/v1/echo \ -H "Content-Type: application/json" -d '{"name":"Ada"}' done curl -si -X POST localhost:3000/api/v1/echo -d 'not json' | head -n 12Export GET too with a helper that returns 405 and an Allow header. Then add a v2 folder that re-exports nothing yet, and decide which fields would justify it.
export const GET = () => apiError(405, "method_not_allowed", "This endpoint only accepts POST.", "Send POST.", { Allow: "POST" });
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why does a 405 response need an Allow header?Show the answer
Answer:MDN says the server must generate an Allow header in a 405 response, listing the methods the resource currently supports. It tells the caller what it should have sent, so it can fix the request.
2.Why does the v1 file re-export the old handler instead of copying it?Show the answer
Answer:Two copies would drift apart, and the deprecated alias would quietly behave differently from v1. A re-export keeps one implementation behind both URLs until the alias is removed.
3.What is the difference between the Deprecation and Sunset headers?Show the answer
Answer:Deprecation (RFC 9745) says a resource is or will be deprecated, with a date that may be in the past. Sunset (RFC 8594) says when it is likely to stop responding, as an HTTP-date. One announces the retirement, the other sets its deadline.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.