Dev Pattern Labs logo
Dev Pattern Labs Blog
Next.js 11 min readOct 12, 2026

Building REST APIs with Next.js Route Handlers

Learn to build production-ready REST APIs with Next.js Route Handlers. Covers GET, POST, PUT, DELETE methods, NextRequest/NextResponse, query params, CORS, streaming responses, and dynamic segments.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

API documentation and code on a monitor
Executive Summary & Key Insight

Learn to build production-ready REST APIs with Next.js Route Handlers. Covers GET, POST, PUT, DELETE methods, NextRequest/NextResponse, query params, CORS, streaming responses, and dynamic segments. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Advertisement

Next.js 13 introduced the App Router alongside a powerful new primitive called Route Handlers — the App Router's replacement for the older Pages Router pages/api convention. Route Handlers let you create custom HTTP endpoints directly inside your app directory using the familiar Web API standards (Request, Response) rather than Node.js-specific abstractions. This alignment with web standards makes your API code more portable, easier to test, and simpler to reason about.

Route Handlers are defined in special route.ts (or route.js) files and support every HTTP method you need for a REST API: GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. Because they live inside the app directory, they participate fully in Next.js features such as caching, static generation, and middleware — giving you a genuinely unified fullstack framework rather than a bolt-on API layer. You can co-locate your API logic right next to the UI components that consume it, keeping your codebase cohesive and maintainable.

In this article we will build a realistic task management REST API step by step, covering every major Route Handler concept: file structure, HTTP methods, NextRequest/NextResponse, query parameters, request body validation, CORS headers, dynamic route segments, and streaming responses. By the end you will have a solid foundation to ship production-quality APIs entirely within Next.js — no separate Express or Fastify server required.


Prerequisites

Before diving in, make sure you are comfortable with the following:

  • Node.js 18+ installed locally
  • A Next.js 13.4+ project bootstrapped with create-next-app (App Router enabled)
  • Basic TypeScript knowledge
  • Familiarity with HTTP concepts (methods, status codes, headers)

Start a fresh project if needed:

npx create-next-app@latest my-api --typescript --app --no-src-dir
cd my-api

1. Route Handler File Structure

Inside the app directory, a route.ts file at any path becomes an HTTP endpoint matching that path. The key rule is that a directory cannot contain both a page.tsx and a route.ts — if you need API logic alongside a page, nest the route.ts one level deeper.

app/
├── api/
│   └── tasks/
│       ├── route.ts          ← handles /api/tasks  (GET all, POST new)
│       └── [id]/
│           └── route.ts      ← handles /api/tasks/:id (GET, PUT, DELETE)
└── page.tsx

Each exported function in a route.ts file corresponds to an HTTP method. Unimplemented methods automatically return 405 Method Not Allowed.

// app/api/tasks/route.ts

export async function GET(request: Request) {
  return Response.json({ message: "List all tasks" });
}

export async function POST(request: Request) {
  return Response.json({ message: "Create a task" }, { status: 201 });
}

That is all it takes to have a working API endpoint — no router setup, no middleware registration, no app.listen().


2. Implementing GET and POST Handlers

Let us build real handlers backed by an in-memory store (swap this for your database of choice in production).

// app/api/tasks/route.ts
import { NextResponse } from "next/server";

interface Task {
  id: string;
  title: string;
  completed: boolean;
  createdAt: string;
}

// In-memory store — replace with Prisma/Drizzle/etc. in production
const tasks: Task[] = [
  {
    id: "1",
    title: "Learn Next.js Route Handlers",
    completed: false,
    createdAt: new Date().toISOString(),
  },
];

// GET /api/tasks — return all tasks
export async function GET() {
  return NextResponse.json({ data: tasks, total: tasks.length });
}

// POST /api/tasks — create a new task
export async function POST(request: Request) {
  const body = await request.json();

  if (!body.title || typeof body.title !== "string") {
    return NextResponse.json(
      { error: "title is required and must be a string" },
      { status: 400 }
    );
  }

  const newTask: Task = {
    id: crypto.randomUUID(),
    title: body.title.trim(),
    completed: false,
    createdAt: new Date().toISOString(),
  };

  tasks.push(newTask);
  return NextResponse.json({ data: newTask }, { status: 201 });
}

The GET handler needs no argument because it does not inspect the request. The POST handler reads the JSON body via request.json() and performs a simple presence check before inserting the record.


3. PUT and DELETE Methods

The [id] dynamic segment handles operations on a single resource.

// app/api/tasks/[id]/route.ts
import { NextResponse } from "next/server";

// Shared in-memory store — in a real app use a DB or shared module
declare const tasks: Array<{ id: string; title: string; completed: boolean; createdAt: string }>;

type RouteContext = { params: Promise<{ id: string }> };

// PUT /api/tasks/:id — full update
export async function PUT(request: Request, { params }: RouteContext) {
  const { id } = await params;
  const body = await request.json();
  const index = tasks.findIndex((t) => t.id === id);

  if (index === -1) {
    return NextResponse.json({ error: "Task not found" }, { status: 404 });
  }

  tasks[index] = {
    ...tasks[index],
    title: body.title ?? tasks[index].title,
    completed: body.completed ?? tasks[index].completed,
  };

  return NextResponse.json({ data: tasks[index] });
}

// DELETE /api/tasks/:id — remove a task
export async function DELETE(_request: Request, { params }: RouteContext) {
  const { id } = await params;
  const index = tasks.findIndex((t) => t.id === id);

  if (index === -1) {
    return NextResponse.json({ error: "Task not found" }, { status: 404 });
  }

  const [removed] = tasks.splice(index, 1);
  return NextResponse.json({ data: removed });
}

Note that params in Next.js 15 is a Promise and must be awaited. This is a breaking change from earlier App Router versions — always await params to stay forward compatible.


4. NextRequest and NextResponse Types

Next.js ships two extended types on top of the standard Web API:

TypeExtendsExtra capabilities
NextRequestRequest.nextUrl, .cookies, .geo, .ip
NextResponseResponse.cookies, static .json(), .redirect(), .rewrite(), .next()

Switching from Request to NextRequest unlocks the parsed .nextUrl object and built-in cookie helpers:

// app/api/auth/session/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  // Read a cookie without manually parsing headers
  const token = request.cookies.get("session_token")?.value;

  if (!token) {
    return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
  }

  // Set a response cookie
  const response = NextResponse.json({ valid: true });
  response.cookies.set("last_seen", new Date().toISOString(), {
    httpOnly: true,
    path: "/",
  });

  return response;
}

Always import NextRequest and NextResponse from "next/server" — not from "next".


5. Reading Query Parameters

NextRequest.nextUrl.searchParams gives you a URLSearchParams instance with zero boilerplate:

// app/api/tasks/route.ts  (updated GET handler)
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;

  const page = Number(searchParams.get("page") ?? "1");
  const limit = Math.min(Number(searchParams.get("limit") ?? "10"), 100);
  const completed = searchParams.get("completed"); // "true" | "false" | null

  let results = [...tasks];

  if (completed !== null) {
    const isDone = completed === "true";
    results = results.filter((t) => t.completed === isDone);
  }

  const start = (page - 1) * limit;
  const paginated = results.slice(start, start + limit);

  return NextResponse.json({
    data: paginated,
    pagination: { page, limit, total: results.length },
  });
}

A request to /api/tasks?page=2&limit=5&completed=false will page through incomplete tasks neatly.


6. Reading and Validating the Request Body

For production APIs, always validate incoming payloads. Here is a pattern using Zod:

// app/api/tasks/route.ts  (robust POST handler)
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const CreateTaskSchema = z.object({
  title: z.string().min(1).max(200),
  completed: z.boolean().optional().default(false),
});

export async function POST(request: NextRequest) {
  let body: unknown;

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

  const parsed = CreateTaskSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 422 }
    );
  }

  const newTask = {
    id: crypto.randomUUID(),
    ...parsed.data,
    createdAt: new Date().toISOString(),
  };

  tasks.push(newTask);
  return NextResponse.json({ data: newTask }, { status: 201 });
}

Install Zod with npm install zod. The safeParse pattern returns a typed result without throwing, letting you return a structured 422 response with field-level error details.


7. Adding CORS Headers

When your Next.js API is consumed by a separate frontend origin, you need CORS headers. The cleanest approach is a reusable helper:

// lib/cors.ts
import { NextRequest, NextResponse } from "next/server";

const ALLOWED_ORIGINS = [
  "https://app.example.com",
  "http://localhost:3000",
];

export function withCors(
  request: NextRequest,
  response: NextResponse
): NextResponse {
  const origin = request.headers.get("origin") ?? "";

  if (ALLOWED_ORIGINS.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
  }

  response.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
  response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
  response.headers.set("Vary", "Origin");

  return response;
}

// Handle pre-flight requests
export function OPTIONS() {
  return new NextResponse(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

Then wrap your responses at the handler level:

import { withCors } from "@/lib/cors";

export async function GET(request: NextRequest) {
  const response = NextResponse.json({ data: tasks });
  return withCors(request, response);
}

export { OPTIONS } from "@/lib/cors";

8. Dynamic Segments in Route Handlers

Nested dynamic segments work just like in page routes:

app/api/users/[userId]/posts/[postId]/route.ts
// app/api/users/[userId]/posts/[postId]/route.ts
import { NextRequest, NextResponse } from "next/server";

type Context = {
  params: Promise<{ userId: string; postId: string }>;
};

export async function GET(request: NextRequest, { params }: Context) {
  const { userId, postId } = await params;

  // Fetch post belonging to user from your database
  const post = await db.post.findFirst({
    where: { id: postId, authorId: userId },
  });

  if (!post) {
    return NextResponse.json({ error: "Post not found" }, { status: 404 });
  }

  return NextResponse.json({ data: post });
}

You can also use catch-all segments ([...slug]) or optional catch-all segments ([[...slug]]) for wildcard routes, which is handy for proxy endpoints or file-serving APIs.


9. Streaming Responses

Route Handlers support the Web Streams API natively, making it straightforward to build server-sent events or stream large payloads:

// app/api/stream/route.ts
import { NextRequest } from "next/server";

export const runtime = "edge"; // optional — works on Node.js runtime too

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const items = ["Processing step 1...", "Processing step 2...", "Done!"];

      for (const item of items) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ message: item })}\n\n`)
        );
        // Simulate async work between chunks
        await new Promise((resolve) => setTimeout(resolve, 500));
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

On the client, consume this with the EventSource API or the fetch streaming API via response.body.getReader(). Streaming is especially powerful for AI-generated content, real-time dashboards, or large CSV exports.


HTTP Methods Reference

MethodTypical UseIdempotentBody
GETRead a resource or collection✅❌
POSTCreate a new resource❌✅
PUTFull replacement update✅✅
PATCHPartial update❌✅
DELETERemove a resource✅Optional
OPTIONSCORS pre-flight negotiation✅❌
HEADMetadata check (no body)✅❌

Next.js Route Handlers natively export handlers for all seven methods. Any method without a matching export returns 405 Method Not Allowed automatically.


Conclusion

Next.js Route Handlers bring a clean, standards-based API layer directly into the App Router without requiring a separate server process. Here are the key takeaways from this guide:

  • File-based routing — place route.ts anywhere in app/ and export named functions matching HTTP methods. One file = one endpoint.
  • NextRequest / NextResponse — extend the native Web API with cookie helpers, .nextUrl, redirect/rewrite utilities, and more.
  • Query params are first-class — request.nextUrl.searchParams gives you a full URLSearchParams with no string-parsing boilerplate.
  • Validate every payload — pair Zod (or your preferred schema library) with safeParse for structured 422 responses and full TypeScript inference.
  • CORS is manual but composable — a small helper function and an exported OPTIONS handler is all you need; consider centralising this in middleware for large APIs.
  • Dynamic segments — [id] and [...slug] segments work identically to page routes, with params available (and in Next.js 15, awaitable) in the second handler argument.
  • Streaming is built-in — return a ReadableStream inside a Response to stream SSE, AI tokens, or bulk data to any client that speaks the Fetch API.

Route Handlers are now the idiomatic way to build APIs in Next.js. Whether you are building a lightweight BFF (Backend for Frontend), a public REST API, or a webhooks endpoint, Route Handlers give you the full power of the Web platform with the ergonomics of Next.js's file-system router.

Tags:#Next.js#REST API#Route Handlers#App Router
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Advertisement

Recommended Companion Articles

Developer working on Next.js caching configuration
Next.js

Next.js 15 Caching Strategies Explained

Understand all Next.js 15 caching layers: fetch cache options, Route Segment Config, full-route cache, data cache, router cache, and on-demand revalidation with revalidatePath and revalidateTag.

13 min read