All Articles
Backend & API Development

How to Structure Your Next.js API Routes So They Don't Become a Mess at Scale

Most Next.js projects start with API routes dumped in one folder with no separation of concerns. Here is the pattern that keeps your API clean, testable, and maintainable as the project grows.

Velox Studio8 min read

Next.js API routes are one of the most convenient features in the framework and one of the most abused.

The convenience is real. You add a file to the app/api directory, export a handler function, and you have a working API endpoint. No separate server. No CORS configuration. No deployment pipeline to maintain. It just works.

The abuse happens gradually. The first endpoint is clean. The second one copies the pattern. By the tenth, business logic is mixed with request handling, error handling is inconsistent, and the authentication check is either duplicated across every handler or missing from half of them.

By the time the project has thirty endpoints, the API is a mess. Every endpoint is different. Nothing is testable in isolation. Adding a new endpoint means reading the existing ones to figure out what the pattern is supposed to be — and there is no single answer.

This does not have to happen. A clear structure applied from the start prevents it.

The Problem With Flat API Route Folders

The default approach is to put everything in app/api/ with one file per endpoint:

app/
  api/
    users/
      route.ts        # GET all users, POST create user
    users/[id]/
      route.ts        # GET user, PUT update, DELETE
    posts/
      route.ts
    auth/
      route.ts

This structure is fine for two or three endpoints. It breaks down at scale for one reason: the route file is doing too many jobs.

A typical route file ends up containing request parsing, validation, authentication checks, business logic, database queries, error handling, and response formatting — all mixed together in a single function. Reading it is hard. Testing it is harder. Changing one part risks breaking another.

The fix is separation of concerns. Each layer has one job.

The Pattern That Scales

The structure that works in production separates your API into four distinct layers:

src/
  app/
    api/
      users/
        route.ts          # HTTP layer only
      posts/
        route.ts
  lib/
    api/
      services/           # Business logic
        user.service.ts
        post.service.ts
      repositories/       # Database queries
        user.repository.ts
        post.repository.ts
      validators/         # Request validation schemas
        user.validator.ts
        post.validator.ts
      middleware/         # Reusable middleware
        auth.middleware.ts
        rate-limit.middleware.ts
    types/
      api.types.ts        # Shared API types

Each layer has a single, clear responsibility.

Route files handle HTTP only. They parse the request, call the appropriate service function, and return a response. No business logic. No database queries. Just the HTTP translation layer.

Services contain business logic. They receive typed inputs, apply business rules, call repositories, and return typed outputs. They know nothing about HTTP — no Request objects, no Response objects. This is the layer where the actual work happens.

Repositories handle database queries. They receive typed parameters and return typed results. They know nothing about business logic or HTTP. Swapping your ORM or database later means changing only this layer.

Validators define and enforce the shape of incoming requests. Using Zod or another schema validation library at this layer means you always know exactly what shape of data your service receives.

What a Route File Should Look Like

A route file in this pattern is thin. Here is what a POST /api/users endpoint looks like:

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/api/middleware/auth.middleware'
import { createUserSchema } from '@/lib/api/validators/user.validator'
import { createUser } from '@/lib/api/services/user.service'

export async function POST(request: NextRequest) {
  return withAuth(request, async (user) => {
    const body = await request.json()
    const validation = createUserSchema.safeParse(body)

    if (!validation.success) {
      return NextResponse.json(
        { error: 'Invalid request', details: validation.error.flatten() },
        { status: 400 }
      )
    }

    const result = await createUser(validation.data, user.id)
    return NextResponse.json(result, { status: 201 })
  })
}

The route file does four things: authenticate the request, validate the body, call the service, return the response. Nothing more.

Building Reusable Middleware

Authentication is the most common logic that gets duplicated across route files. Every endpoint needs to verify the user is logged in — and if that check lives inside each route file, you will eventually miss it somewhere.

The solution is a middleware wrapper:

typescript
// lib/api/middleware/auth.middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

type AuthenticatedHandler = (
  request: NextRequest,
  user: AuthUser
) => Promise<NextResponse>

export async function withAuth(
  request: NextRequest,
  handler: AuthenticatedHandler
): Promise<NextResponse> {
  const supabase = createClient()
  const { data: { user }, error } = await supabase.auth.getUser()

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

  return handler(request, user)
}

Now authentication is applied by wrapping the handler, not by copying code into it. You cannot forget it — it is part of the pattern. You cannot get it wrong — it is tested once in isolation.

The same pattern works for rate limiting, logging, and any other cross-cutting concern.

Consistent Error Handling

Inconsistent error responses are one of the most common API problems. One endpoint returns { error: 'Not found' }, another returns { message: 'User not found' }, a third throws an unhandled exception and returns a 500 with an HTML error page.

Define a standard error response shape and a utility function that creates it:

typescript
// lib/api/utils/response.ts
import { NextResponse } from 'next/server'

export function errorResponse(
  message: string,
  status: number,
  details?: unknown
) {
  return NextResponse.json(
    { error: message, ...(details && { details }) },
    { status }
  )
}

export function successResponse<T>(data: T, status = 200) {
  return NextResponse.json(data, { status })
}

Every route file uses these utilities. Every response has the same shape. Frontend code can handle errors consistently. Debugging is faster because you always know what to expect.

When to Move Off Next.js API Routes

Next.js API routes are the right choice for most projects. But there are situations where they are not enough.

Move to a dedicated backend when you need heavy background processing. Next.js API routes run in a serverless environment with execution time limits. Long-running jobs, complex queues, and scheduled tasks need a different runtime.

Move when your API needs to serve multiple clients at significant scale. A Next.js API route that serves a web app, a mobile app, and third-party integrations under heavy load will benefit from a dedicated API service with more control over caching, rate limiting, and infrastructure.

Move when your team specialises. If your backend team works in Go or Python, forcing them into Next.js API routes to maintain consistency with the frontend is the wrong trade-off. Use the right tool for each team.

For everything else — especially MVPs, agency projects, and products at early to mid scale — Next.js API routes with a clean separation of concerns will take you further than most teams ever need to go.

The Payoff

A well-structured API is invisible. Routes are easy to read. Adding a new endpoint takes minutes because the pattern is clear. Business logic is testable without making HTTP requests. Authentication is consistent because it cannot be skipped.

The structure costs nothing extra to set up from day one. It saves significant time every week after that.

Start with the pattern. The API stays clean from the first endpoint to the hundredth.

Building a Next.js app that needs a solid API layer?

We build full-stack Next.js applications with clean, scalable API architecture for agencies and startups. Fast delivery, production-ready code.

View Full-Stack Development

Tags

Next.jsAPI routesbackendNode.jsarchitecturescalabilityREST APITypeScript

V

Velox Studio

AI-Powered Development Studio

Share

Related Articles

Startup & MVP Development

The Tech Stack Decision That Will Either Save or Kill Your MVP

Founders overthink stack decisions or blindly copy what big companies use. Here is how to choose the right stack for your MVP — and why Next.js, Supabase, and Vercel is the right default for most founders right now.

6 min readRead Article
Frontend Architecture & Best Practices

Your Next.js Project Structure Is Slowing Your Team Down

Most teams start Next.js with a flat structure that works at 10 components and breaks at 100. Here is how to organise your project so it scales with your team instead of fighting it.

7 min readRead Article
AI-Powered Development

What AI-Powered Development Actually Does to Your Project Timeline and Budget

AI-powered development cuts build time by 40 to 60 percent. Here is what that means in real numbers for your timeline, your budget, and the quality of what gets delivered.

7 min readRead Article