C
Next.js/API/Lesson 12

Route Handlers — HTTP Endpoints via app/api/.../route.ts

30 min·theory
This chapter
1/2
TypeScript

Route Handlers — HTTP Endpoints via app/api/.../route.ts

💡 Why Learn This? — When Server Actions Alone Are Not Enough

🎯 Server Actions are exclusively for form mutations. HTTP APIs called externally — by mobile apps, webhooks, or third-party services — still require REST endpoints.
💼 The App Router answer: place GET/POST/PUT/DELETE functions as named exports in an `app/api/.../route.ts` file.
Cleaner than the default-export handler of `pages/api/foo.ts` from the Pages Router era — each HTTP method gets its own function, eliminating branching dispatch code.
🔗 Uses the standard `Request` / `Response` objects — aligned with Web Standards. Works unchanged on the Edge Runtime as well.
📈 `NextRequest` / `NextResponse` are also available for richer functionality (cookies, URL parsing, redirects).
🏢 실무에서는
Image upload handling, OAuth callbacks, payment webhooks (Stripe, Toss), REST APIs called by mobile apps, and incoming external webhooks — all of these fall squarely in the domain of Route Handlers. They cannot be handled with Server Actions (Server Actions are exclusively for browser form / React component triggers).

Named Exports · Request/Response · Dynamic Segments

1. File Path = URL

code
app/api/users/route.ts        →  /api/users
app/api/users/[id]/route.ts   →  /api/users/:id
app/api/webhooks/stripe/route.ts → /api/webhooks/stripe

2. Named Exports per HTTP Method

ts
// app/api/users/route.ts
export async function GET(request: Request) {
  const users = await db.user.findMany();
  return Response.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return Response.json(user, { status: 201 });
}

export async function PUT(request: Request) { ... }
export async function DELETE(request: Request) { ... }
export async function PATCH(request: Request) { ... }
// HEAD and OPTIONS are also supported

No more branching with if (req.method === 'POST') per method.

3. Dynamic Segments → params Argument

ts
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } },
) {
  const user = await db.user.findUnique({ where: { id: Number(params.id) } });
  if (!user) return Response.json({ error: 'not found' }, { status: 404 });
  return Response.json(user);
}

4. NextRequest / NextResponse — Richer Functionality

ts
import { type NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  // cookies
  const session = req.cookies.get('session')?.value;

  // query string
  const q = req.nextUrl.searchParams.get('q');

  // headers
  const ua = req.headers.get('user-agent');

  return NextResponse.json({ q, ua, hasSession: !!session });
}

export async function POST(req: NextRequest) {
  const res = NextResponse.json({ ok: true });
  res.cookies.set('session', 'abc123', { httpOnly: true, secure: true });
  return res;
}

5. Caching Behavior — Dynamic by Default

ts
export async function GET() { ... }
// Default: GET uses force-cache (when static is possible); POST/PUT/DELETE are always dynamic

// Force re-execution every time
export const dynamic = 'force-dynamic';

// Force static (only when the request object is not used)
export const dynamic = 'force-static';

6. Division of Responsibility with Server Actions

Use CaseTool
Form submission from a React componentServer Action
Mutation button inside a React componentServer Action
HTTP calls from external services (mobile, webhooks)Route Handler
GET data fetching (outside Server Components, for external callers)Route Handler
OAuth callbackRoute Handler
Receiving file uploadsRoute Handler
💻 🅰️ Pages Router — Default Handler in pages/api/foo.ts
// ❌ Pages Router — req.method branching + res.* calls

// 📁 pages/api/users/index.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (req.method === 'GET') {
    const users = await db.user.findMany();
    return res.status(200).json(users);
  }

  if (req.method === 'POST') {
    const body = req.body;
    const user = await db.user.create({ data: body });
    return res.status(201).json(user);
  }

  res.setHeader('Allow', 'GET, POST');
  return res.status(405).end('Method Not Allowed');
}

// 📁 pages/api/users/[id].ts
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  const id = Number(req.query.id);

  if (req.method === 'GET') {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) return res.status(404).json({ error: 'not found' });
    return res.status(200).json(user);
  }

  if (req.method === 'DELETE') {
    await db.user.delete({ where: { id } });
    return res.status(204).end();
  }

  return res.status(405).end();
}

// Disadvantages:
// - req.method branching boilerplate
// - res.* imperative API (risk of response not closing if only return is used)
// - NextApiRequest / NextApiResponse are Next.js specific types (different from Web Standard)
// - Edge Runtime not supported (Node.js only)
💻 🅱️ App Router — HTTP Method Named Exports in route.ts
// ✅ App Router — named export per method + Web Standard

// 📁 app/api/users/route.ts
import { type NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  // Query: /api/users?role=admin
  const role = req.nextUrl.searchParams.get('role');
  const users = await db.user.findMany({
    where: role ? { role } : undefined,
  });
  return NextResponse.json(users);
}

export async function POST(req: NextRequest) {
  const body = await req.json();
  // Input validation
  if (!body.email) {
    return NextResponse.json({ error: 'email required' }, { status: 400 });
  }
  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

// 📁 app/api/users/[id]/route.ts — Dynamic segment
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  const id = Number(params.id);
  if (Number.isNaN(id)) {
    return NextResponse.json({ error: 'invalid id' }, { status: 400 });
  }
  const user = await db.user.findUnique({ where: { id } });
  if (!user) {
    return NextResponse.json({ error: 'not found' }, { status: 404 });
  }
  return NextResponse.json(user);
}

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  await db.user.delete({ where: { id: Number(params.id) } });
  return new NextResponse(null, { status: 204 });
}

// 📁 app/api/webhooks/stripe/route.ts — Receive external webhooks
import { headers } from 'next/headers';

export async function POST(req: NextRequest) {
  const sig = headers().get('stripe-signature');
  if (!sig) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 });
  }

  const raw = await req.text(); // raw body as is
  try {
    const event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET!);
    if (event.type === 'checkout.session.completed') {
      await db.order.update({
        where: { id: event.data.object.metadata!.orderId },
        data: { status: 'paid' },
      });
    }
    return NextResponse.json({ received: true });
  } catch (e) {
    return NextResponse.json({ error: 'Signature verification failed' }, { status: 400 });
  }
}

// 📁 app/api/upload/route.ts — Faster with Edge Runtime
export const runtime = 'edge';

export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get('file') as File;
  // ... upload processing
  return Response.json({ url: '...' });
}

// Advantages:
// - Method-specific function separation → req.method branching disappears
// - Standard Request/Response → Aligned with Web standards, reduced learning curve ↓
// - Edge Runtime opt-in possible → Faster responses
// - Just return NextResponse.json(...) and you're done (no imperative res.*)

💡 💡 Route Handlers in Practice — Top 5 Tips

1. Named exports per method — no need for switch statements

ts
export async function GET() { ... }
export async function POST() { ... }

Calling an undeclared method automatically returns 405.

2. Dynamic segments go in the second argument: params

ts
export async function GET(req, { params }: { params: { id: string } }) {
  ...
}

The first argument is the request; the second is context (which contains params). Don't confuse them.

3. NextResponse.json() vs Response.json() — nearly the same, but

  • Response.json(): Web standard, Edge-compatible.
  • NextResponse.json(): Extends the standard with conveniences like cookies and redirect.

Use NextResponse when you need to set cookies or redirect; either works for simple JSON responses.

4. Check whether a Server Action is sufficient first
If a mutation is only called from React components, a Server Action is cleaner than a Route Handler. Use Route Handlers only when external callers are involved.

5. Caching policy — only GET is cached; mutating methods are always dynamic

ts
export const dynamic = 'force-dynamic'; // run GET on every request too
export const revalidate = 60;            // 60-second ISR

Use force-dynamic if the database changes frequently; leave the default if it rarely changes.

⚡ Try It Yourself — Route Handler Routing Simulation

Simulate which named export a given URL + HTTP method combination routes to.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Review Quiz

What is the biggest difference between a Route Handler and a Server Action in App Router?
💡 Route Handlers are ordinary HTTP endpoints, so they are **callable from the outside** — by mobile apps, external services, webhooks, and more. Server Actions are an RPC-like concept invoked through React's form actions, `startTransition`, or event handlers, meaning they are **only callable from within React components**. Form mutations go to Server Actions; APIs meant for external callers go to Route Handlers — that is the fundamental split.
Route Handlers — app/api/.../route.ts - Next.js