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.tsThis 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 typesEach 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:
// 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:
// 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:
// 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.