MW

How-To · Frameworks

Next.js API Routes: route.ts + NextRequest / NextResponse Complete Example (2026)

Next.js API routes turn any file in `app/api/` into a serverless endpoint. Here's the App Router pattern with `route.ts`, `NextRequest` / `NextResponse`, dynamic segments, and production traps.

Jan 24, 2025 8 min read Any platform intermediate
Advertisement

Next.js API routes turn any file under app/api/ into a serverless HTTP endpoint. With the App Router (Next.js 13+), each endpoint is a route.ts file that exports named functions per HTTP method.

my-next-app/
app/
api/
hello/
route.ts GET /api/hello
users/
route.ts GET / POST /api/users
[id]/
route.ts GET / PUT / DELETE /api/users/:id
page.tsx
layout.tsx
next.config.js
package.json

This guide covers App Router routes end-to-end: basic GET, all HTTP methods, dynamic segments, auth, errors, and the gotchas you’ll hit in production.

Basic API route

App Router (Next.js 13+)

Create app/api/hello/route.ts:

import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({ message: 'Hello World' });
}

Verify locally:

Terminal — test the endpoint
$ npm run dev
▲ Next.js 14.0.4 - Local: http://localhost:3000 ✓ Ready in 1.8s
$ curl http://localhost:3000/api/hello
{"message":"Hello World"}

Pages Router (legacy)

If you’re still on the Pages Router, create pages/api/hello.ts:

import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  res.status(200).json({ message: 'Hello World' });
}

HTTP methods — GET / POST / PUT / DELETE

Export one named function per HTTP method you want to support:

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

export async function GET() {
  const users = await db.users.findMany();
  return NextResponse.json(users);
}

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

export async function PUT(request: NextRequest) {
  const body = await request.json();
  // ...
}

export async function DELETE() {
  // ...
}

Any method not exported returns 405 Method Not Allowed automatically — Next.js handles that for you.

Request handling

Read query string params

// GET /api/search?q=astro&limit=10
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get('q');
  const limit = parseInt(searchParams.get('limit') || '10', 10);

  return NextResponse.json({ query, limit });
}

Read JSON body (POST / PUT)

export async function POST(request: NextRequest) {
  const body = await request.json();
  // body is your parsed JSON
  return NextResponse.json({ received: body });
}

Read headers

import { headers } from 'next/headers';

export async function GET() {
  const headersList = headers();
  const userAgent = headersList.get('user-agent');
  return NextResponse.json({ userAgent });
}

Read FormData (file uploads)

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  const buffer = Buffer.from(await file.arrayBuffer());
  // ... save buffer to disk / S3 / etc.
  return NextResponse.json({ filename: file.name, size: file.size });
}

Response handling

NextResponse.json() is the standard way to return JSON:

return NextResponse.json({ ok: true }, { status: 201 });

For other response types:

// Plain text
return new NextResponse('Hello world', {
  headers: { 'Content-Type': 'text/plain' },
});

// Redirect
import { redirect } from 'next/navigation';
redirect('/login');  // 307 by default

// Custom status + headers
return NextResponse.json(
  { error: 'Not found' },
  { status: 404, headers: { 'Cache-Control': 'no-store' } }
);

// Streaming response
const stream = new ReadableStream({
  async start(controller) {
    controller.enqueue('chunk 1');
    controller.enqueue('chunk 2');
    controller.close();
  },
});
return new NextResponse(stream);

Dynamic routes — [id] segments

Folder name in brackets = dynamic segment. The value is available as a context arg:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await db.users.findUnique({ where: { id: params.id } });
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }
  return NextResponse.json(user);
}

Catch-all segments use [...slug]:

// app/api/files/[...path]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { path: string[] } }
) {
  // /api/files/docs/readme.md → params.path = ['docs', 'readme.md']
  const fullPath = params.path.join('/');
  return NextResponse.json({ fullPath });
}

Authentication

A common pattern: middleware-style auth check at the top of each handler:

// app/api/private/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';

export async function GET(request: NextRequest) {
  const session = await auth();

  if (!session || !session.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Authorized — return private data
  return NextResponse.json({ secret: 'authorized!', user: session.user });
}

For shared auth across multiple routes, factor it into a helper:

// lib/api-auth.ts
export async function requireAuth(request: NextRequest) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  return session;
}

// app/api/whatever/route.ts
export async function GET(request: NextRequest) {
  const session = await requireAuth(request);
  if (session instanceof NextResponse) return session;  // early-return on 401
  // proceed with session.user
}

Error handling

Wrap handlers in try/catch and return consistent error shapes:

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const result = await db.thing.create({ data: body });
    return NextResponse.json(result, { status: 201 });
  } catch (error) {
    console.error('API error:', error);

    if (error instanceof ZodError) {
      return NextResponse.json(
        { error: 'Invalid input', issues: error.issues },
        { status: 400 }
      );
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Best practices

  1. One file per resourceapp/api/users/route.ts for collections, app/api/users/[id]/route.ts for individual records. Keeps URL design clean.
  2. Validate input with Zod.parse() at the top of POST/PUT handlers; the catch-block then handles ZodError uniformly.
  3. Don’t put DB logic in route.ts — extract to lib/ modules. Routes become thin transport adapters around your business logic.
  4. Set Cache-Control headers explicitly — Next.js will cache by default in some configurations. For dynamic endpoints, cache: 'no-store' or revalidate: 0.
  5. Use Edge runtime when CPU-boundexport const runtime = 'edge' at the top of route.ts switches from Node to Vercel’s Edge runtime (faster cold starts, but limited Node APIs).
export const runtime = 'edge';  // opt into Edge runtime

export async function GET() {
  return NextResponse.json({ message: 'Running on the edge!' });
}

Common pitfalls

SymptomCauseFix
405 Method Not AllowedYou didn’t export that HTTP methodAdd export async function POST(...)
Route returns cached old dataNext.js cached the responseSet export const dynamic = 'force-dynamic' or use cache: 'no-store' headers
params.id is undefinedFile isn’t in a [id] folderMove route.ts under app/api/users/[id]/ (note the brackets)
Auth check returns Promise<NextResponse>Forgot await on session helperawait auth()
Body parsing failsTrying request.body directly (Node-style)Use await request.json() instead

Conclusion

Next.js API routes via the App Router boil down to:

  1. Create route.ts in app/api/<path>/
  2. Export named functions per HTTP method — GET, POST, PUT, DELETE
  3. Return NextResponse.json() (or a streaming response)
  4. Use [id] for dynamic segments, [...slug] for catch-all
  5. Check auth at the top of any non-public handler

Once you’ve got the file structure right, every API endpoint becomes a 10-line function. The rest is just business logic.

Next.js API Backend REST Web Development
Advertisement

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.