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 four-layer pattern that keeps your API clean, testable, and maintainable as it scales from 5 endpoints to 100+.

Velox Studio13 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.

App Router vs Pages Router for API Routes

Before getting into the pattern, one quick clarification. Next.js supports two routing models, and the API layer differs in each.

Pages Router (pages/api/) - the older model. Each file exports a default handler function that receives req and res Express-style. Still supported and works fine for existing projects.

App Router (app/api/route.ts) - the current default since Next.js 13.2. Each file exports named functions for each HTTP method (GET, POST, PUT, etc.) that receive a Request object and return a Response.

The patterns in this article apply to both, but the code examples use App Router because it is what we use on every new build. If you are on Pages Router and considering migration, the patterns transfer cleanly - only the route file syntax changes.

For broader Next.js project structure patterns (not just the API layer), see your Next.js project structure is slowing your team down.

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.

A Full Real-World Example: User Authentication Endpoint

To make this concrete, here is what the same POST /api/users endpoint looks like across all four layers.

The validator

typescript
// lib/api/validators/user.validator.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  role: z.enum(['admin', 'editor', 'viewer']),
})

export type CreateUserInput = z.infer<typeof createUserSchema>

The repository

typescript
// lib/api/repositories/user.repository.ts
import { prisma } from '@/lib/db'
import type { CreateUserInput } from '@/lib/api/validators/user.validator'

export async function findUserByEmail(email: string) {
  return prisma.user.findUnique({ where: { email } })
}

export async function insertUser(data: CreateUserInput, createdBy: string) {
  return prisma.user.create({
    data: { ...data, createdBy },
  })
}

The service

typescript
// lib/api/services/user.service.ts
import { findUserByEmail, insertUser } from '@/lib/api/repositories/user.repository'
import type { CreateUserInput } from '@/lib/api/validators/user.validator'

export async function createUser(input: CreateUserInput, createdBy: string) {
  const existing = await findUserByEmail(input.email)
  if (existing) {
    throw new ConflictError('User with this email already exists')
  }

  return insertUser(input, createdBy)
}

The route (back to where we started)

typescript
// app/api/users/route.ts (already shown above)

Notice what each layer knows and does not know:

  • The route knows about HTTP. It does not know about Zod schemas or the database.
  • The service knows about business rules (no duplicate emails). It does not know HTTP or how the database is queried.
  • The repository knows the database. It does not know business rules or HTTP.
  • The validator knows the shape of input data. It does not know anything else.

This separation is the entire point. Each piece is independently testable, replaceable, and reasonable in isolation.

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.

typescript
// Composing multiple middleware
export async function POST(request: NextRequest) {
  return withRateLimit(request, async () => {
    return withAuth(request, async (user) => {
      return withLogging(request, async () => {
        // Actual handler logic here
      })
    })
  })
}

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.

The most common reasons APIs fail in production - and the assumptions that cause them - are covered in detail in why your API returns 500s in production (and not in dev).

Performance Considerations

Once the structure is in place, a few performance patterns matter for production APIs.

Edge runtime for fast, stateless routes. Routes that do not need Node.js APIs (no file system, no native modules) can run on Vercel Edge for dramatically lower latency. Add export const runtime = 'edge' to the route file. Auth checks, lightweight CRUD, and read-heavy routes are usually good candidates.

Caching with revalidate. For routes that return data that does not change often, set export const revalidate = 60 (seconds) at the top of the route file. This lets Next.js cache the response between requests.

Avoid blocking imports at the top of route files. Anything imported at the top of a route file is loaded on every cold start. Heavy imports (large libraries, database clients with significant init time) should be dynamically imported inside the handler when possible.

Use connection pooling for serverless databases. Next.js API routes in production typically run serverless. Each invocation can spin up a new database connection if you are not careful. Use a pool-aware client (PgBouncer for Postgres, Prisma's Data Proxy, Supabase's pooler) to avoid connection exhaustion under load.

For data fetching strategy patterns that complement this API structure on the frontend, see your Next.js app does not have a performance problem, it has a data fetching problem.

Testing the Four-Layer Pattern

A key benefit of the four-layer pattern is that each layer is independently testable.

Test services without HTTP. Services receive typed inputs and return typed outputs. You can unit test them by calling them directly with test data. No mocking Express, no test HTTP server.

typescript
// user.service.test.ts
import { createUser } from '@/lib/api/services/user.service'

describe('createUser', () => {
  it('rejects duplicate emails', async () => {
    await createUser({ email: 'existing@test.com', name: 'A', role: 'admin' }, 'creator-id')
    await expect(
      createUser({ email: 'existing@test.com', name: 'B', role: 'editor' }, 'creator-id')
    ).rejects.toThrow(ConflictError)
  })
})

Test repositories with a test database. A real database (typically a Postgres container or sqlite for unit tests) gives you confidence that your queries actually work.

Test routes with HTTP mocks. When you do want to test the full HTTP layer, mock the service and repository layers and assert that the route handles requests correctly.

The flat-route pattern makes this kind of layered testing nearly impossible. The four-layer pattern makes it natural.

Common Mistakes to Avoid

Patterns we see most often that break the structure even when teams know better.

Putting business logic in the validator. Validators check shape. They do not check business rules. "Email must be a valid email" is shape. "Email must not already exist" is business logic - that lives in the service.

Importing repositories directly into route files. When the route file imports a repository, the service layer has been bypassed. This usually starts as "just one quick endpoint that does not need any business logic." It always ends with business logic being added later in the wrong place.

Inconsistent middleware application. If withAuth is applied to some endpoints and not others without a clear reason, the pattern stops being a pattern. Either every endpoint that needs auth uses it, or define a different category (public endpoints) explicitly.

Generic services. A userService that handles everything user-related becomes a god object. Split by concern - userOnboardingService, userProfileService, userAuthService - when the service file gets past 200 lines.

Throwing strings instead of typed errors. throw 'user not found' makes error handling impossible to reason about. Define typed errors (NotFoundError, ConflictError, AuthError) and throw those. The route layer can then map them to status codes cleanly.

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.

If your Node.js API is breaking under load specifically, the patterns in how to build a REST API with Node.js that doesn't break under load cover what to fix.

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.

Frequently Asked Questions

Should I use App Router or Pages Router for API routes in 2026? App Router for new projects. It is the current default since Next.js 13.2 and gets all new framework features. Pages Router still works and is officially supported, but new patterns and improvements land in App Router first. The four-layer separation in this article applies to both.

Where should I put business logic in a Next.js API route? In a service module under lib/api/services/, not in the route file. The route file should only translate HTTP requests to service calls and back. Business logic in the route file makes it impossible to test or reuse outside of an HTTP context.

Do I need a separate backend if I am using Next.js? For most projects, no. Next.js API routes handle authentication, CRUD, integrations with external APIs, and most business logic at the scale of typical web products. Consider a separate backend only when you need heavy background processing, multiple client types, or your team specialises in a different stack.

How do I handle authentication in Next.js API routes? Use a middleware wrapper pattern. Define a withAuth function that verifies the request, attaches user context, and calls the handler. Every authenticated endpoint wraps its logic in this function. Cannot be forgotten because it is part of the pattern.

What is the best way to validate request bodies in Next.js API routes? Zod is the standard. Define a schema per endpoint, call safeParse on the request body, return a 400 with the validation errors if it fails. The validator file becomes the source of truth for the shape of each endpoint's input.

Should I use the Edge runtime or Node.js runtime for Next.js API routes? Edge for stateless, fast routes that do not need Node.js APIs (no file system, no native modules). Node.js for routes that need database connections, image processing, or any Node-specific library. Most CRUD APIs work fine on Node.js; auth and lightweight read endpoints often benefit from Edge.

How do I prevent my Next.js API from collapsing under traffic spikes? Rate limiting, connection pooling, caching with revalidate, and stateless handlers that can scale horizontally. Avoid in-memory state across requests, use Edge runtime where possible, and load-test before launch.

Can I use this four-layer pattern with tRPC or GraphQL instead of REST? Yes. The pattern is about separating HTTP/transport concerns from business logic and data access. tRPC procedures replace the route file. GraphQL resolvers replace the route file. The service and repository layers stay identical. The separation matters more than the transport mechanism.

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 Next.js Development

Tags

Next.jsAPI routesbackendNode.jsarchitecturescalabilityREST APITypeScriptApp RouterAPI design

V

Velox Studio

AI-Powered Development Studio

Share

Related Articles

Backend & API Development

REST vs GraphQL: How to Choose for Your Next Web App

Both work. Both ship products to production every day. But they are not interchangeable - the right choice depends on factors most teams ignore until they regret the decision. Here is the honest framework.

11 min readRead Article
Backend & API Development

Why Your API Returns 500s in Production (And Not in Dev)

Your API works perfectly on your laptop. In production, it returns 500s under load. The bugs are not in your code. They are in the assumptions you made when you wrote it. Here are the patterns we see fail most often.

8 min readRead Article
Backend & API Development

How to Build a REST API with Node.js That Doesn't Break Under Load

Most Node.js APIs work fine in development and fall apart in production. Here is how to build one that holds up when it actually matters.

7 min readRead Article