Next.js Interview Prep
API Routes and Backend

Route Handlers (App Router)

Your Backend Inside Next.js

LinkedIn Hook

"We had a separate Express server for 11 API endpoints. Separate deployments, separate CI pipelines, separate monitoring dashboards, separate cold starts."

Then we discovered Route Handlers in Next.js App Router and consolidated everything into the same codebase, the same deployment, the same edge network.

The result? Fewer moving parts, simpler architecture, and API latency dropped because the backend was now co-located with the frontend that consumed it.

But here's the thing most developers get wrong: Route Handlers and Server Actions are not the same thing, and picking the wrong one leads to unnecessary complexity.

In Lesson 6.1, you'll learn how route.ts works, how to export HTTP method functions, how to read Request objects and build Response objects, how dynamic params work, and when to use Route Handlers vs Server Actions.

Read the full lesson -> [link]

#NextJS #RouteHandlers #API #Backend #WebDevelopment #InterviewPrep #React #AppRouter


Route Handlers (App Router) thumbnail


What You'll Learn

  • What Route Handlers are and how they replace the Pages Router's api/ routes
  • The route.ts file convention and how Next.js maps files to API endpoints
  • How to export named functions for GET, POST, PUT, PATCH, and DELETE
  • How to work with the Web Standard Request and Response objects (plus NextRequest and NextResponse)
  • How dynamic route segments work in Route Handlers
  • When to use Route Handlers vs Server Actions — and why the choice matters

The Receptionist Analogy — A Hotel Front Desk

Think of your Next.js application as a hotel.

Pages (page.tsx) are the hotel rooms. Guests stay in them, experience them, interact with the furniture. Each room is designed for human occupation — that is your UI.

Route Handlers (route.ts) are the front desk. The front desk doesn't serve guests directly in the visual sense — nobody "stays" at the front desk. Instead, the front desk receives specific requests ("I need fresh towels", "Book me a spa session", "Check me out") and responds with structured answers ("Towels are on the way", "Booking confirmed for 3 PM", "Your bill is $420").

The front desk handles programmatic interactions: external services calling your hotel (webhooks), the hotel's own app making requests (your frontend fetching data), and third-party partners integrating with your system (public APIs).

Just as a hotel room and the front desk both exist within the same building but serve fundamentally different purposes, page.tsx and route.ts both live inside your app/ directory but serve fundamentally different consumers. One serves HTML to humans. The other serves data to programs.


The route.ts File Convention

In the App Router, you create API endpoints by placing a route.ts (or route.js) file inside the app/ directory. The file's location in the folder structure determines the URL path — exactly like page.tsx does for pages.

app/
  api/
    users/
      route.ts          ->  GET /api/users, POST /api/users
      [id]/
        route.ts        ->  GET /api/users/123, PUT /api/users/123, DELETE /api/users/123
    products/
      route.ts          ->  GET /api/products
      search/
        route.ts        ->  GET /api/products/search?q=keyboard
  page.tsx              ->  The homepage (HTML)

Critical rule: A route.ts and a page.tsx cannot exist in the same directory at the same route level. They would conflict — Next.js would not know whether to return HTML or data for a request to that path. If you need both a page and an API endpoint at the same URL, put the API in a sub-path (e.g., /dashboard/page.tsx and /dashboard/api/route.ts).

// This is INVALID - route.ts and page.tsx in the same directory
app/
  dashboard/
    page.tsx     // serves HTML at /dashboard
    route.ts     // tries to serve data at /dashboard -- CONFLICT

// This is VALID - separated into different paths
app/
  dashboard/
    page.tsx     // serves HTML at /dashboard
    api/
      route.ts   // serves data at /dashboard/api

HTTP Method Exports — The Core Pattern

Unlike Express or Fastify where you call app.get() or app.post(), in Next.js Route Handlers you export named functions that match HTTP method names. Next.js automatically routes the correct HTTP method to the correct function.

GET — Retrieving Data

// app/api/users/route.ts

// Named export must match the HTTP method exactly (uppercase)
export async function GET() {
  // In a real app, this data would come from a database
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
    { id: 3, name: 'Charlie', email: 'charlie@example.com' },
  ];

  // Return a JSON response using the Web Standard Response API
  return Response.json(users);
}

// curl http://localhost:3000/api/users
// Response: [{"id":1,"name":"Alice",...}, {"id":2,"name":"Bob",...}, ...]

POST — Creating Data

// app/api/users/route.ts (same file as GET above)

export async function POST(request: Request) {
  // Parse the JSON body from the incoming request
  const body = await request.json();

  // Validate the incoming data
  if (!body.name || !body.email) {
    return Response.json(
      { error: 'Name and email are required' },
      { status: 400 }
    );
  }

  // In a real app, you would insert into a database here
  const newUser = {
    id: Date.now(), // placeholder ID generation
    name: body.name,
    email: body.email,
    createdAt: new Date().toISOString(),
  };

  // Return 201 Created with the new resource
  return Response.json(newUser, { status: 201 });
}

// curl -X POST http://localhost:3000/api/users \
//   -H "Content-Type: application/json" \
//   -d '{"name": "Diana", "email": "diana@example.com"}'
// Response: {"id":1712847200000,"name":"Diana","email":"diana@example.com",...}

PUT and DELETE — Updating and Removing Data

// app/api/users/[id]/route.ts

// PUT replaces the entire resource
export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();

  // In a real app, update the user in the database
  const updatedUser = {
    id: Number(id),
    name: body.name,
    email: body.email,
    updatedAt: new Date().toISOString(),
  };

  return Response.json(updatedUser);
}

// PATCH updates partial fields (similar structure, different semantics)
export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();

  // Only update the fields that were sent
  // In a real app, merge with existing database record
  const patchedUser = {
    id: Number(id),
    ...body, // only the fields the client sent
    updatedAt: new Date().toISOString(),
  };

  return Response.json(patchedUser);
}

// DELETE removes the resource
export async function DELETE(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  // In a real app, delete from database
  // Return 204 No Content for successful deletion (no body)
  return new Response(null, { status: 204 });
}

// curl -X DELETE http://localhost:3000/api/users/42
// Response: (empty body, 204 status)

All supported HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. If a client sends an unsupported method (e.g., you only export GET but someone sends a POST), Next.js automatically returns 405 Method Not Allowed.


Request and Response Objects

Route Handlers use the Web Standard API — the same Request and Response objects that exist in browsers, Service Workers, and Cloudflare Workers. Next.js also provides extended versions: NextRequest and NextResponse.

Reading from the Request

// app/api/products/search/route.ts

import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  // -- Reading URL and search params --
  // NextRequest extends Request with convenient helpers
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get('q') || '';
  const page = parseInt(searchParams.get('page') || '1', 10);
  const limit = parseInt(searchParams.get('limit') || '20', 10);

  // -- Reading headers --
  const authHeader = request.headers.get('Authorization');
  const contentType = request.headers.get('Content-Type');
  const userAgent = request.headers.get('User-Agent');

  // -- Reading cookies --
  // NextRequest has a .cookies helper (Web Standard Request does not)
  const sessionToken = request.cookies.get('session')?.value;
  const theme = request.cookies.get('theme')?.value || 'dark';

  // Simulate a search against a database
  const results = await searchProducts(query, page, limit);

  return Response.json({
    query,
    page,
    limit,
    totalResults: results.total,
    items: results.items,
  });
}

// Helper function (would normally query a real database)
async function searchProducts(query: string, page: number, limit: number) {
  return {
    total: 42,
    items: [
      { id: 1, name: `${query} Keyboard`, price: 79.99 },
      { id: 2, name: `${query} Mouse`, price: 49.99 },
    ],
  };
}

// curl "http://localhost:3000/api/products/search?q=wireless&page=1&limit=10"

Building the Response

// app/api/reports/route.ts

import { NextResponse } from 'next/server';

export async function GET() {
  const reportData = { revenue: 125000, orders: 842, avgOrderValue: 148.46 };

  // Method 1: Simple JSON response (Web Standard)
  // return Response.json(reportData);

  // Method 2: NextResponse with custom headers and cookies
  const response = NextResponse.json(reportData);

  // Set custom response headers
  response.headers.set('X-Report-Generated', new Date().toISOString());
  response.headers.set('Cache-Control', 'private, max-age=60');

  // Set cookies on the response
  response.cookies.set('last-report-view', new Date().toISOString(), {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24, // 24 hours
  });

  return response;
}

Handling Different Content Types

// app/api/export/route.ts

export async function GET(request: Request) {
  const url = new URL(request.url);
  const format = url.searchParams.get('format') || 'json';

  const data = [
    { name: 'Alice', score: 95 },
    { name: 'Bob', score: 87 },
  ];

  // Return JSON
  if (format === 'json') {
    return Response.json(data);
  }

  // Return CSV
  if (format === 'csv') {
    const csv = 'name,score\n' + data.map(d => `${d.name},${d.score}`).join('\n');
    return new Response(csv, {
      headers: {
        'Content-Type': 'text/csv',
        'Content-Disposition': 'attachment; filename="export.csv"',
      },
    });
  }

  // Return plain text
  if (format === 'text') {
    const text = data.map(d => `${d.name}: ${d.score}`).join('\n');
    return new Response(text, {
      headers: { 'Content-Type': 'text/plain' },
    });
  }

  return Response.json({ error: 'Unsupported format' }, { status: 400 });
}

// curl "http://localhost:3000/api/export?format=csv"
// Response: name,score\nAlice,95\nBob,87

Dynamic Route Handlers

Dynamic segments work identically to dynamic pages. You use [paramName] folder notation, and the parameter value is extracted from the URL.

// app/api/posts/[slug]/comments/[commentId]/route.ts

// URL: /api/posts/intro-to-nextjs/comments/42

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string; commentId: string }> }
) {
  // Extract both dynamic params
  const { slug, commentId } = await params;

  // slug = "intro-to-nextjs"
  // commentId = "42"

  // Fetch the specific comment from the database
  const comment = await getComment(slug, commentId);

  if (!comment) {
    return Response.json(
      { error: `Comment ${commentId} not found on post "${slug}"` },
      { status: 404 }
    );
  }

  return Response.json(comment);
}

// Catch-all dynamic route example:
// app/api/files/[...path]/route.ts
// Matches: /api/files/docs/2024/report.pdf

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params;
  // path = ["docs", "2024", "report.pdf"]

  const filePath = path.join('/');
  // filePath = "docs/2024/report.pdf"

  return Response.json({ requestedFile: filePath });
}

Caching Behavior in Route Handlers

An important nuance that catches developers off guard in interviews:

// app/api/time/route.ts

// GET handlers with no dynamic input are CACHED by default (like SSG)
// This response will be generated at BUILD TIME and served statically
export async function GET() {
  return Response.json({ time: new Date().toISOString() });
}
// Every request returns the SAME time -- the build time. Surprising!

// To make it dynamic (fresh on every request), you have several options:

// Option 1: Export dynamic config
export const dynamic = 'force-dynamic';

// Option 2: Read from the Request object (makes it inherently dynamic)
export async function GET(request: Request) {
  const url = new URL(request.url);
  return Response.json({ time: new Date().toISOString() });
}

// Option 3: Use NextRequest (also inherently dynamic)
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
  return Response.json({ time: new Date().toISOString() });
}

// Option 4: Use cookies() or headers()
import { cookies } from 'next/headers';
export async function GET() {
  const cookieStore = await cookies();
  return Response.json({ time: new Date().toISOString() });
}

Key interview point: GET Route Handlers that do not read from the Request object and do not use dynamic functions are treated as static and cached at build time. POST, PUT, PATCH, and DELETE handlers are always dynamic and never cached.


Route Handlers vs Server Actions — When to Use Which

This is one of the most common Next.js interview questions in 2025-2026 and is where many candidates stumble. Both Route Handlers and Server Actions run on the server, but they serve different purposes.

+------------------------------------------------------+
|                                                      |
|   Route Handlers (route.ts)                          |
|   Purpose: HTTP endpoints for ANY client             |
|                                                      |
|   - External webhooks (Stripe, GitHub, Twilio)       |
|   - Mobile apps consuming your API                   |
|   - Third-party integrations                         |
|   - Public/documented REST or GraphQL APIs           |
|   - File downloads, streaming responses              |
|   - Non-mutating data fetching from client           |
|                                                      |
+------------------------------------------------------+
|                                                      |
|   Server Actions ('use server')                      |
|   Purpose: Mutations called from YOUR React UI       |
|                                                      |
|   - Form submissions                                 |
|   - Button click mutations (add to cart, like, etc.) |
|   - Data updates triggered by user interaction       |
|   - Any mutation tightly coupled to your UI           |
|                                                      |
+------------------------------------------------------+

Detailed Comparison

FeatureRoute HandlersServer Actions
File conventionroute.ts'use server' directive in function or file
InvocationHTTP request (fetch, curl, external client)Direct function call from React component
HTTP methodsGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSAlways POST (under the hood)
Accessible externallyYes — any client with the URL can call itNo — only your own React frontend
URLExplicit URL path (e.g., /api/users)Auto-generated internal endpoint
Request/ResponseFull control over Request and Response objectsArguments in, return value out (like an RPC)
CachingGET can be cached; other methods always dynamicAlways dynamic (mutations should never cache)
Use for data fetchingYes — especially for external consumersNo — use Server Components for reads
Progressive enhancementMust be wired manuallyForms work without JavaScript (built-in)
StreamingYes — return ReadableStreamLimited (via useActionState for pending UI)

Code Comparison

// ROUTE HANDLER approach: app/api/todos/route.ts
// Good for: External API, mobile app consumption, webhook endpoint

export async function POST(request: Request) {
  const body = await request.json();

  // Validate, sanitize, save to database
  const todo = await db.todos.create({
    data: { title: body.title, completed: false },
  });

  return Response.json(todo, { status: 201 });
}

// Client-side usage:
// const res = await fetch('/api/todos', {
//   method: 'POST',
//   headers: { 'Content-Type': 'application/json' },
//   body: JSON.stringify({ title: 'Learn Route Handlers' }),
// });
// SERVER ACTION approach: app/actions/todos.ts
// Good for: Form submissions directly from your UI
'use server';

import { revalidatePath } from 'next/cache';

export async function createTodo(formData: FormData) {
  const title = formData.get('title') as string;

  // Validate, sanitize, save to database
  await db.todos.create({
    data: { title, completed: false },
  });

  // Revalidate the page that shows the todo list
  revalidatePath('/todos');
}

// Usage in a React component:
// <form action={createTodo}>
//   <input name="title" />
//   <button type="submit">Add Todo</button>
// </form>
//
// This form works even with JavaScript disabled (progressive enhancement)

The decision rule: If the consumer is your own React UI performing a mutation, use a Server Action. If the consumer is anything else (external service, mobile app, public API, or your frontend needs a GET endpoint), use a Route Handler.

Route Handlers (App Router) visual 1


Common Mistakes

  • Putting route.ts and page.tsx in the same directory. This is an invalid configuration. Next.js cannot serve both a page and an API endpoint at the same URL path. The route handler takes precedence and the page becomes inaccessible. Move the route handler to a sub-path like api/.

  • Assuming GET Route Handlers are always dynamic. A GET handler that does not read the Request object and uses no dynamic functions is static by default — it is rendered once at build time and cached. This means new Date() in a static GET handler returns the build time forever, not the current time. Add export const dynamic = 'force-dynamic' or read from the Request object to make it dynamic.

  • Using Route Handlers for mutations in your own UI when Server Actions would be simpler. If you are building a form in your React frontend that creates a resource, a Server Action lets you call the server function directly without manually wiring up fetch, parsing the response, or handling Content-Type headers. Reaching for a Route Handler adds unnecessary HTTP plumbing.

  • Forgetting to await params in the App Router. In Next.js 15+, the params object passed to Route Handlers is a Promise. Forgetting to await it will give you the Promise object instead of the actual parameter values. This is a breaking change from earlier versions where params was a plain object.

  • Not handling errors or returning appropriate status codes. Returning 200 OK for everything, including validation failures and not-found resources, makes your API confusing for consumers. Use 400 for bad input, 401 for unauthenticated, 403 for unauthorized, 404 for missing resources, and 500 for unexpected server errors.


Interview Questions

1. What is a Route Handler in Next.js App Router, and how does it differ from the Pages Router's API Routes?

Route Handlers use the route.ts file convention inside the app/ directory, compared to the Pages Router's pages/api/ directory. The key differences are: Route Handlers export named functions matching HTTP methods (GET, POST, etc.) instead of a single default export that checks req.method. Route Handlers use the Web Standard Request and Response objects instead of the Node.js-specific req/res objects. Route Handlers support the Edge runtime natively. And Route Handlers support static caching for GET requests by default, which Pages Router API routes never did.

2. A junior developer writes a GET Route Handler that returns new Date().toISOString(), but the timestamp never changes between requests. What's happening and how do you fix it?

The GET handler is being statically cached at build time because it does not read from the Request object or use any dynamic functions. Next.js optimizes it as a static response. To fix it, either add export const dynamic = 'force-dynamic' to the file, accept the Request parameter and use it (even reading request.url is enough), use NextRequest as the parameter type, or call a dynamic function like cookies() or headers().

3. When should you use a Route Handler vs a Server Action? Give a concrete example for each.

Use a Route Handler when the API endpoint needs to be accessible by external clients — for example, a /api/webhooks/stripe endpoint that receives Stripe payment events, or a /api/v1/products endpoint consumed by a mobile app. Use a Server Action when the mutation is triggered from your own React UI — for example, a "Add to Cart" button that calls a 'use server' function to update the cart in the database and revalidates the page. Server Actions provide progressive enhancement (forms work without JS) and eliminate the fetch boilerplate.

4. Can a route.ts and a page.tsx coexist in the same directory? Why or why not?

No. Having both in the same directory creates a routing conflict because Next.js cannot determine whether a request to that path should return HTML (page) or data (route handler). The route handler takes precedence and the page becomes unreachable. The solution is to place the route handler in a sub-path, such as adding an api/ subdirectory.

5. How do you access dynamic URL parameters and query string parameters in a Route Handler?

Dynamic URL parameters (like [id] in /api/users/[id]/route.ts) are available via the second argument's params property, which is a Promise in Next.js 15+: const { id } = await params. Query string parameters are accessed through the URL: either by using new URL(request.url).searchParams with the standard Request, or more conveniently via request.nextUrl.searchParams when using NextRequest from next/server.


Quick Reference -- Cheat Sheet

ConceptKey Point
File conventionroute.ts inside app/ directory defines an API endpoint
Folder = URLapp/api/users/route.ts -> /api/users
Method exportsexport async function GET/POST/PUT/PATCH/DELETE()
Request objectWeb Standard Request or extended NextRequest
Response objectWeb Standard Response or extended NextResponse
Dynamic params{ params }: { params: Promise<{ id: string }> } — must await
Query paramsrequest.nextUrl.searchParams.get('key') via NextRequest
GET cachingStatic by default (cached at build); use force-dynamic to opt out
POST/PUT/DELETEAlways dynamic, never cached
Conflict ruleroute.ts and page.tsx cannot coexist in the same directory
vs Server ActionsRoute Handlers = external API; Server Actions = internal UI mutations
Unsupported methodAutomatically returns 405 Method Not Allowed
+---------------------------------------------------+
|          Route Handlers Mental Model              |
+---------------------------------------------------+
|                                                   |
|  File: app/api/[resource]/route.ts                |
|                                                   |
|  Export named functions for HTTP methods:          |
|    GET    -> Read data                            |
|    POST   -> Create data                          |
|    PUT    -> Replace data                         |
|    PATCH  -> Partial update                       |
|    DELETE -> Remove data                          |
|                                                   |
|  Request: Web Standard Request / NextRequest      |
|  Response: Web Standard Response / NextResponse   |
|                                                   |
|  Decision rule:                                   |
|  "Who is calling this endpoint?"                  |
|    External client  -> Route Handler              |
|    Your own React UI (mutation) -> Server Action  |
|    Your own React UI (read)     -> Server Component|
|                                                   |
+---------------------------------------------------+

Previous: Lesson 5.4 -- Middleware Next: Lesson 6.2 -- API Design in Next.js


This is Lesson 6.1 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.

On this page