March 15, 2026·7 min·By

Next.js Route Handlers: Building APIs That Don't Suck

Next.jsAPI routesRoute HandlersRESTbackend

Next.js Route Handlers replace the old pages/api directory. They're more powerful and align with Web API standards. Here's how to build production-quality APIs with them.

Basic Structure

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

export async function GET(request: NextRequest) {
    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')

    const users = await db.users.findMany({
        skip: (page - 1) * 20,
        take: 20,
        orderBy: { createdAt: 'desc' }
    })

    return NextResponse.json({ data: users, page })
}

export async function POST(request: NextRequest) {
    const body = await request.json()
    const user = await db.users.create({ data: body })
    return NextResponse.json({ data: user }, { status: 201 })
}
// app/api/users/[id]/route.ts
export async function GET(
    request: NextRequest,
    { params }: { params: Promise<{ id: string }> }
) {
    const { id } = await params
    const user = await db.users.findById(id)

    if (!user) {
        return NextResponse.json(
            { error: { code: 'NOT_FOUND', message: 'User not found' } },
            { status: 404 }
        )
    }

    return NextResponse.json({ data: user })
}

Middleware for Auth

// lib/api-middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from './auth'

type Handler = (req: NextRequest, context: any, user: User) => Promise<NextResponse>

export function withAuth(handler: Handler) {
    return async (req: NextRequest, context: any) => {
        const token = req.headers.get('authorization')?.replace('Bearer ', '')

        if (!token) {
            return NextResponse.json(
                { error: { code: 'UNAUTHORIZED', message: 'No token provided' } },
                { status: 401 }
            )
        }

        const user = await verifyToken(token)
        if (!user) {
            return NextResponse.json(
                { error: { code: 'UNAUTHORIZED', message: 'Invalid token' } },
                { status: 401 }
            )
        }

        return handler(req, context, user)
    }
}

// Usage
export const GET = withAuth(async (req, context, user) => {
    const data = await getDataForUser(user.id)
    return NextResponse.json({ data })
})

Request Validation

import { z } from 'zod'

const CreateUserSchema = z.object({
    email: z.string().email(),
    name: z.string().min(1).max(100),
    role: z.enum(['user', 'admin']).default('user')
})

export async function POST(request: NextRequest) {
    let body: unknown
    try {
        body = await request.json()
    } catch {
        return NextResponse.json(
            { error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON' } },
            { status: 400 }
        )
    }

    const parsed = CreateUserSchema.safeParse(body)
    if (!parsed.success) {
        return NextResponse.json(
            {
                error: {
                    code: 'VALIDATION_ERROR',
                    message: 'Invalid request body',
                    details: parsed.error.flatten().fieldErrors
                }
            },
            { status: 400 }
        )
    }

    const user = await db.users.create({ data: parsed.data })
    return NextResponse.json({ data: user }, { status: 201 })
}

Error Handling

// lib/api-errors.ts
export class ApiError extends Error {
    constructor(
        public statusCode: number,
        public code: string,
        message: string
    ) {
        super(message)
    }
}

// Wrap handlers to catch errors
export function withErrorHandling(handler: (req: NextRequest, ctx: any) => Promise<NextResponse>) {
    return async (req: NextRequest, ctx: any) => {
        try {
            return await handler(req, ctx)
        } catch (error) {
            if (error instanceof ApiError) {
                return NextResponse.json(
                    { error: { code: error.code, message: error.message } },
                    { status: error.statusCode }
                )
            }
            console.error('Unhandled error:', error)
            return NextResponse.json(
                { error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
                { status: 500 }
            )
        }
    }
}

Rate Limiting

import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.slidingWindow(100, '1 m'),  // 100 requests per minute
})

export function withRateLimit(handler: Handler) {
    return async (req: NextRequest, ctx: any) => {
        const identifier = req.headers.get('x-forwarded-for') || 'anonymous'
        const { success, reset } = await ratelimit.limit(identifier)

        if (!success) {
            return NextResponse.json(
                { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } },
                {
                    status: 429,
                    headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) }
                }
            )
        }

        return handler(req, ctx)
    }
}

CORS for External Consumers

export async function OPTIONS(request: NextRequest) {
    return new NextResponse(null, {
        status: 200,
        headers: {
            'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type, Authorization',
            'Access-Control-Max-Age': '86400',
        }
    })
}

function corsHeaders(origin: string) {
    return {
        'Access-Control-Allow-Origin': origin,
        'Vary': 'Origin'
    }
}

Compose these middlewares for clean route handlers:

export const POST = withErrorHandling(withRateLimit(withAuth(
    async (req, ctx, user) => {
        // Clean handler logic here
        const data = await doWork(user)
        return NextResponse.json({ data })
    }
)))
K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation