Skip to main content

API Routes

Next.js App Router uses Route Handlers to create API endpoints. These are defined in route.ts files inside the app/ directory and give you full control over HTTP requests and responses.

Your First Route Handler

Create app/api/hello/route.ts:

import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({ message: "Hello from the API" });
}

This responds to GET /api/hello with a JSON payload. Each exported function name corresponds to an HTTP method.

Handling All CRUD Methods

Here is a complete example for a /api/posts endpoint:

import { NextRequest, NextResponse } from "next/server";

type Post = {
  id: string;
  title: string;
  content: string;
};

// In-memory store for demonstration
const posts: Post[] = [];

// GET /api/posts
export async function GET() {
  return NextResponse.json(posts);
}

// POST /api/posts
export async function POST(request: NextRequest) {
  const body = await request.json();
  const post: Post = {
    id: crypto.randomUUID(),
    title: body.title,
    content: body.content,
  };
  posts.push(post);
  return NextResponse.json(post, { status: 201 });
}

For routes that need a dynamic parameter like /api/posts/[id], create app/api/posts/[id]/route.ts:

import { NextRequest, NextResponse } from "next/server";

// GET /api/posts/:id
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const post = posts.find((p) => p.id === id);
  if (!post) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(post);
}

// PUT /api/posts/:id
export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const index = posts.findIndex((p) => p.id === id);
  if (index === -1) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  posts[index] = { ...posts[index], ...body };
  return NextResponse.json(posts[index]);
}

// DELETE /api/posts/:id
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const index = posts.findIndex((p) => p.id === id);
  if (index === -1) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  posts.splice(index, 1);
  return new NextResponse(null, { status: 204 });
}

Working with Request Data

Route handlers give you access to headers, cookies, query parameters, and the request body:

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  // Query parameters
  const searchParams = request.nextUrl.searchParams;
  const page = parseInt(searchParams.get("page") ?? "1");
  const limit = parseInt(searchParams.get("limit") ?? "10");

  // Headers
  const authHeader = request.headers.get("authorization");

  // Cookies
  const token = request.cookies.get("session-token")?.value;

  return NextResponse.json({
    page,
    limit,
    authenticated: !!authHeader,
    hasSession: !!token,
  });
}

Setting Response Headers and Cookies

export async function GET() {
  const response = NextResponse.json({ status: "ok" });

  // Custom headers
  response.headers.set("X-Request-Id", crypto.randomUUID());
  response.headers.set("Cache-Control", "s-maxage=60, stale-while-revalidate");

  // Set a cookie
  response.cookies.set("visited", "true", {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24, // 1 day
  });

  return response;
}

Middleware for Route Handlers

Create middleware.ts at the project root to run logic before route handlers execute:

import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  // Rate limiting example: check a header
  const apiKey = request.headers.get("x-api-key");

  if (request.nextUrl.pathname.startsWith("/api/admin") && !apiKey) {
    return NextResponse.json({ error: "API key required" }, { status: 401 });
  }

  // Add a custom header and continue
  const response = NextResponse.next();
  response.headers.set("X-Middleware", "active");
  return response;
}

export const config = {
  matcher: "/api/:path*",
};

Error Handling Pattern

Wrap route handler logic in try/catch for clean error responses:

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const data = PostSchema.parse(body);

    // ... create the post

    return NextResponse.json(data, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validation failed", details: error.errors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Summary

  • Route handlers live in route.ts files and export functions named after HTTP methods.
  • Use NextRequest to read query params, headers, cookies, and body.
  • Use NextResponse to send JSON, set headers, and manage cookies.
  • Middleware runs before route handlers and is useful for auth checks and rate limiting.
  • Always validate input and handle errors gracefully.