Next.js fundamentals

Build and Call an API Endpoint with Next.js App Router Route Handlers

Build an App Router API endpoint with GET and POST Route Handlers, validate JSON input, call it with fetch, and check the responses.

Editorial illustration for Build and Call an API Endpoint with Next.js App Router Route Handlers

To handle /api/todos in the Next.js App Router, create app/api/todos/route.ts and export functions named for the HTTP methods you want to accept. A Route Handler receives a Web Request and returns a Web Response; there is no Pages Router-style req and res pair to set up.

Map /api/todos to a Route Handler

The file path is the URL map: app/api/todos/route.ts handles requests to /api/todos. Route Handler files use the name route.ts or route.js and live inside app, including nested folders such as app/api/todos. The Next.js Route Handlers documentation also notes that a route file cannot share the same route segment level as a page file.

If you know the older pages/api convention, this is its App Router counterpart—not a second file you need to create for the same endpoint. Next.js describes the distinction in its API-building guide.

Implement GET and POST in one file

Create app/api/todos/route.ts:

const sampleTodos = [{ title: "Review route handlers" }];

export function GET(_request: Request): Response {
  return Response.json({ todos: sampleTodos }, { status: 200 });
}

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Expected a JSON body" }, { status: 400 });
  }

  if (
    body === null ||
    typeof body !== "object" ||
    Array.isArray(body) ||
    !("title" in body) ||
    typeof body.title !== "string" ||
    body.title.trim() === ""
  ) {
    return Response.json(
      { error: "title must be a non-empty string" },
      { status: 400 }
    );
  }

  const todo = { title: body.title.trim() };

  // Add project-specific storage logic here if the todo must persist.
  return Response.json({ todo }, { status: 201 });
}

The GET handler returns a list with status 200. POST parses the request body, rejects malformed JSON or a missing, non-string, or blank title with a JSON error and status 400, and returns the accepted representation with status 201. These are two named method exports in one file, not two endpoints.

The POST example does not save anything. Its response represents the submitted todo, but a later GET still returns the hard-coded sample list. Replace the comment with your project's storage operation—and make GET read from that storage—if you need created todos to appear in subsequent requests. Returning 201 alone cannot make data persist.

Call the endpoint with fetch

These browser-side functions can be called from a client-side interaction. Both expect JSON from the handlers above. Parse the response, then check ok before treating its contents as a successful result:

async function loadTodos() {
  const response = await fetch("/api/todos");
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error ?? `GET failed (${response.status})`);
  }

  return data.todos;
}

async function submitTodo(title: string) {
  const response = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error ?? `POST failed (${response.status})`);
  }

  return data.todo;
}

For an invalid title, submitTodo reads the handler's JSON error and throws rather than handing an error object to code expecting a todo. The functions use a relative URL because the requests are shown running in the browser against the same application. They do not assume that every possible response from Next.js is JSON; they are written for the JSON responses defined in this file.

Check the URL, bodies, and status codes

With the application running locally at http://localhost:3000, curl -i shows response headers as well as the body:

curl -i http://localhost:3000/api/todos

curl -i -X POST http://localhost:3000/api/todos \
  -H 'Content-Type: application/json' \
  -d '{"title":"Write a test"}'

curl -i -X POST http://localhost:3000/api/todos \
  -H 'Content-Type: application/json' \
  -d '{"title":"   "}'

curl -i -X DELETE http://localhost:3000/api/todos

For this example, expect status 200 and {"todos":[{"title":"Review route handlers"}]} from GET; 201 and {"todo":{"title":"Write a test"}} from the valid POST; and 400 with {"error":"title must be a non-empty string"} from the blank-title POST. There is no DELETE export, so expect 405 Method Not Allowed for that request. Check its status rather than assuming the framework-generated response has the same JSON shape as your handlers' responses. The Route Handlers documentation lists the supported HTTP methods and specifies the 405 behavior for an unsupported method.

Keep GET behavior predictable

Route Handlers are not cached by default, according to the current App Router documentation. A GET handler can opt into static caching with a route configuration such as export const dynamic = 'force-static'; the other methods in the file remain uncached. Only make that choice when the GET response can be static. It fits the fixed sample list, but it is the wrong assumption to carry into a GET that must reflect newly stored todos. Leave caching unconfigured for this walkthrough's endpoint.

Find a note

Search by topic, title, or keyword.