Key Takeaway
To build an API in Next.js, create a route.ts file inside the app folder, for example app/api/hello/route.ts, and export a function named after the HTTP method, such as GET or POST. Parse the body inside try and catch, validate every field, answer with the right status code, and keep API keys in environment variables without the NEXT_PUBLIC prefix.
You have a nice contact form, and now the Send button has to actually do something. You need an address on the server that accepts the data, checks it and sends an email. This is where many beginners put the file in the wrong place, get a confusing 405 or 404, or leave an API key in code that ships to the browser.
This guide builds one small API from scratch with Route Handlers in Next.js 15 (App Router): GET, POST, validation, consistent errors, calling it from the client and a contact form endpoint. Every statement about Next.js and fetch was checked against the official documentation listed in the sources, and the pattern is the same one the contact route on this site uses.
Want the concepts first, with an interactive simulation?
This guide focuses on steps and errors. Our free lesson covers rate limiting, API versioning and the flow of one request through a safe route, with a quiz: Learn Next.js API Routes: Validation, Rate Limit, Versioning. Do this guide first so you have a working route, then continue with the lesson to make it hard to break.
What you need
All of this runs on your laptop, with no server and no extra cost.
- A Next.js 15 project using the App Router (the app folder), for example from create-next-app. This guide uses TypeScript.
- The dev server running with npm run dev, usually at http://localhost:3000.
- curl in a terminal, to try the API before you build any form.
- One .env.local file in the project root for the last step.
Step 1: Create your first route.ts and call it with GET
A Route Handler is a route.ts or route.js file inside the app folder. In it you export one function for each HTTP method you want to serve: GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS. The function name must be in capital letters exactly like that. Response.json builds a JSON body and sets the Content-Type header to application/json.
// app/api/hello/route.ts
// The function name is the HTTP method, in capital letters.
export async function GET() {
return Response.json({ message: "Halo dari route handler" });
}
// In a second terminal, while "npm run dev" is running:
// curl -i http://localhost:3000/api/helloExpected output: curl prints an HTTP 200 status line, a content-type header of application/json, and a body with a message saying Halo dari route handler.
The same folder cannot hold both route.ts and page.tsx. The Next.js docs list app/page.js and app/route.js as a conflict, while app/api/route.js is valid. That is why an API almost always sits in a subfolder such as api.
Step 2: Add POST and read the JSON body
You read the request body with a standard Web API method, request.json(). It throws if the body is empty or not valid JSON, so wrap it in try and catch. Otherwise the client's mistake turns into a 500 that looks like your server's fault. Answer 400 with a code a machine can read.
// app/api/contact/route.ts
export async function POST(request: Request) {
// Wrong: const body = await request.json();
// an empty or broken body throws, and the visitor gets a 500.
// Right: catch it and answer 400, because the client made the mistake.
let body: { name?: unknown };
try {
body = await request.json();
} catch {
return Response.json(
{ success: false, code: "invalid_json" },
{ status: 400 },
);
}
return Response.json({ success: true, received: body });
}
// curl -i -X POST http://localhost:3000/api/contact \
// -H "Content-Type: application/json" \
// -d '{"name":"Ada"}'
//
// And the broken version, to see the 400:
// curl -i -X POST http://localhost:3000/api/contact -d 'bukan json'Expected output: With a valid JSON body you get status 200 and success set to true. With a body that is not JSON you get status 400 and the code invalid_json.
The Pages Router used to need a bodyParser setting. Route Handlers do not. Just call request.json(), or request.formData() if your form sends multipart data.
Step 3: Validate input before you use it
Treat the body as untrusted text. First force every field into a safe shape: anything that is not a string becomes an empty string, then it is trimmed and cut to a maximum length. Only then check the rules. This order prevents a TypeError when someone sends a number or an object in a field you assumed was text.
// Wrong: trusts the shape. If name is the number 42, trim() throws -> 500.
// const name = body.name.trim();
// Right: turn anything that is not a string into "", trim, cut to a maximum.
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 message = clamp(body.message, 5000);
// Only now check the rules. The hint tells the caller what to fix.
if (name.length < 2) {
return Response.json(
{ success: false, code: "invalid_request", hint: "name must be 2 to 100 characters." },
{ status: 400 },
);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return Response.json(
{ success: false, code: "invalid_request", hint: "email must be a valid address." },
{ status: 400 },
);
}
if (message.length < 10) {
return Response.json(
{ success: false, code: "invalid_request", hint: "message must be at least 10 characters." },
{ status: 400 },
);
}Every rejection should name the field that is wrong and the range that is right, so the caller can fix the request without reading your source. The contact route on this site uses the same pattern: clamp first, check after.
Expected output: A one-letter name gets 400 invalid_request with a hint that mentions name. An email without an at sign gets 400 with a hint that mentions email.
Step 4: Pick status codes and unify the error shape
A status code states the category: 400 for a bad request, 405 for an unsupported method, 429 for too many requests, 500 for a server misconfiguration, and 502 when another service you call fails. One small helper that builds every error keeps the shape identical, so the client reads a code instead of guessing from a sentence.
// lib/api-error.ts
export function apiError(
status: number,
code: string,
message: string,
hint: string,
): Response {
return Response.json(
{ success: false, error: message, code, hint },
// Response.json takes the status in its second argument.
{ status, headers: { "Cache-Control": "no-store" } },
);
}
// Wrong: the second argument is the options object, not a number
// return Response.json({ error: "Invalid" }, 400);
// Right:
return apiError(400, "invalid_request", "Invalid name.", "name must be 2 to 100 characters.");Look at the second argument of Response.json. It is an options object holding status, statusText and headers, not a number. So the status goes in as the status property of that object. Cache-Control no-store on errors keeps failed answers from being stored.
Step 5: Read the query string and a dynamic segment
You read the query string through request.nextUrl.searchParams. For a URL like /api/items/42, create a folder named with square brackets, app/api/items/[id]/route.ts. Since Next.js 15, the params the function receives is a Promise, so you must await it before use.
// app/api/search/route.ts -> GET /api/search?query=hello
import { type NextRequest } from "next/server";
export function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("query") ?? "";
return Response.json({ query });
}
// app/api/items/[id]/route.ts -> GET /api/items/42
export async function GET(
request: Request,
// Wrong (Next.js 15): { params }: { params: { id: string } } then params.id
// Right: params is a Promise, so type it that way and await it
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
return Response.json({ id });
}Expected output: Calling /api/search?query=hello returns query with the value hello. Calling /api/items/42 returns id with the value 42.
If you copy an older example that writes params.id without await, that code assumes params is already a plain object. In Next.js 15 that is no longer true. The Promise type and await above are the form the current docs show.
Step 6: Call the API from the client with fetch
From a client component, send a POST with a Content-Type of application/json and a body from JSON.stringify. The biggest trap is here: fetch only rejects on network-level failures. If the server answers 400 or 500, the promise still succeeds and you get an ordinary Response. Check res.ok yourself.
"use client";
// app/contact/ContactForm.tsx (the form's submit handler)
async function submit(data: { name: string; email: string; message: string }) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
// Wrong: assume a 400 or 500 lands in a catch block. It does not.
// fetch() only rejects on network-level failures.
// Right: check res.ok yourself and read the error body.
if (!res.ok) {
const err = await res.json();
throw new Error(err.hint ?? err.error ?? "Request failed");
}
return res.json();
}Because your API's errors already share one shape, the client can simply show err.hint to the user. No nested ifs for each kind of failure.
Step 7: Assemble the contact form endpoint with the secret on the server
Now put it all together. The email provider's API key is read from process.env inside the route, where it exists only on the server. Check that the key exists first, then parse, validate, call the email provider and check the result. If the provider fails, log the detail on the server and answer 502 with a generic message.
# .env.local (project root, never committed)
# Wrong: NEXT_PUBLIC_EMAIL_API_KEY=... (inlined into the browser bundle at build time)
# Right: no prefix, so the value stays on the server
EMAIL_API_KEY=replace-with-your-key
CONTACT_TO_EMAIL=owner@example.com
// app/api/contact/route.ts (final order of the guards)
import { apiError } from "@/lib/api-error";
export async function POST(request: Request) {
// 1. Fail loudly when the server is not configured.
const apiKey = process.env.EMAIL_API_KEY;
const to = process.env.CONTACT_TO_EMAIL;
if (!apiKey || !to) {
return apiError(500, "service_unavailable", "Email is not configured.", "Set EMAIL_API_KEY and CONTACT_TO_EMAIL.");
}
// 2. Parse (400 invalid_json), 3. clamp and validate (400 invalid_request).
// ...the code from steps 2 and 3...
// 4. Call the provider from the server. Check the result yourself.
const sent = await sendEmail(apiKey, to, name, email, message);
if (!sent.ok) {
console.error("email provider error", sent.detail); // detail stays in the server log
return apiError(502, "upstream_error", "Failed to send message.", "Try again later.");
}
return Response.json({ success: true });
}A form that only your own Next.js pages use is sometimes a better fit for Server Actions. The comparison is in the article on Server Actions in production. A Route Handler is still the right choice when your API is called from outside, such as a mobile app or a webhook.
Expected output: Without EMAIL_API_KEY the route answers 500 service_unavailable. With the key and valid input, the route answers success set to true. The key value never appears in the response body or in the browser JavaScript bundle.
Common errors and how to fix them
Read the symptom, match it to the cause, then apply the fix. These eight cases are the ones that trip beginners most often.
| Symptom | Cause | Fix |
|---|---|---|
| POST gets 405 Method Not Allowed | The file only exports GET, or the function name is lowercase like post. | Export export async function POST in all capitals. Next.js answers 405 for a method that is not exported. |
| 404 when opening /api/hello | The file is not at app/api/hello/route.ts, or its name is not route.ts. | Match the folder path to the URL and make sure the file name is exactly route.ts. |
| The dev server or build complains that route and page conflict | route.ts sits in the same folder as page.tsx. | Move route.ts into a subfolder, for example app/api/contact/route.ts. |
| 500 when the body is empty or not JSON | request.json() throws and there is no try and catch. | Wrap the parse in try and catch and answer 400 with the code invalid_json. |
| 500 with a TypeError when calling trim | The field is not a string after all, for example a number or an object. | Coerce through clamp first: a non-string becomes an empty string, then trim and cut. |
| params.id is undefined or errors in a dynamic route | In Next.js 15 params is a Promise, but it is used like a plain object. | Type it as a Promise and write const id from await params. |
| The catch on the client never runs although the server answered 400 | fetch only rejects on network failures, not on HTTP error statuses. | Check res.ok after fetch, then read the error body and throw it yourself. |
| process.env.EMAIL_API_KEY is undefined, or the key shows up in the browser | The .env.local file is in the wrong place, or the key has the NEXT_PUBLIC prefix so its value is inlined into the browser bundle at build time. | Put .env.local in the project root, not in the src folder. Use a name without the prefix, read it only in the route, and never commit env files. |
Checklist before other people use your API
- The route is at app/api/endpoint-name/route.ts and does not sit beside a page.tsx.
- Exported function names are capitalised and match the methods you want to serve.
- request.json() is wrapped in try and catch, and a broken body gets 400.
- Every field is clamped and validated, and each hint names the field and its range.
- All errors go through one helper with a stable code and the right status.
- The API key is read from process.env without the NEXT_PUBLIC prefix, and env files stay out of git.
Go deeper
The route you built here deliberately follows the same pattern as the contact route on this site: parse in try and catch, clamp fields, answer an email provider failure with 502 and a generic message, and build errors in one helper. What we have not covered is limiting how often one client may call the API, versioning the path, and retiring an old path with headers.
Continue with the lesson: Learn Next.js API Routes, Validation, Rate Limit, Versioning
That lesson dissects real code from this site, including a rate limit bug that happened because it trusted a header the client could fake, and ends with an exercise you can run on your laptop.

