50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 22, 2026·7 min read

API Design Patterns for Next.js — REST Conventions That Actually Work

Route handlers, consistent error shapes, versioning, auth middleware — here's how we structure APIs in Next.js so they don't become a mess six months later.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

API Design Patterns for Next.js — REST Conventions That Actually Work

We've built enough Next.js apps to know that your API design decisions in week one will either save you or haunt you in month six. There's no magical framework forcing you to be consistent — Next.js just gives you route handlers and says 'good luck.' So most projects drift into chaos: some endpoints return `{ data: [...] }`, others return `[...]` directly, errors are sometimes 400, sometimes 500, sometimes 200 with `{ error: true }` buried in the body. It's a mess.

This post is about the conventions we've landed on after building peal.dev templates. Not academic REST theory — practical stuff that keeps you sane when you're adding a fifth endpoint at midnight and your brain is running at 40%.

A Consistent Response Envelope — The One Thing Worth Being Religious About

Pick a response shape and never deviate from it. Sounds obvious. Almost nobody does it from day one. Here's what we use:

// lib/api-response.ts
export type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string };

export function ok<T>(data: T, status = 200): Response {
  return Response.json({ success: true, data } satisfies ApiResponse<T>, { status });
}

export function err(
  message: string,
  status = 400,
  code?: string
): Response {
  return Response.json(
    { success: false, error: message, code } satisfies ApiResponse<never>,
    { status }
  );
}

// Usage in a route handler
// app/api/users/[id]/route.ts
import { ok, err } from '@/lib/api-response';

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, params.id),
  });

  if (!user) return err('User not found', 404, 'USER_NOT_FOUND');

  return ok(user);
}

The `code` field on errors is underrated. `'USER_NOT_FOUND'` is way more useful to your frontend than a 404 status code, especially when a 404 could mean multiple different things. The frontend can switch on `code` and show the right message without parsing error strings.

One response shape. No exceptions. Your frontend devs (future-you included) will thank you. If you return arrays directly from some endpoints and objects from others, you'll spend hours debugging why `response.data.map` threw 'map is not a function'.

File Structure That Doesn't Fight You

Next.js App Router's file-based routing is a blessing and a curse. It makes URL structure obvious, but it's easy to end up with 200-line route files because you crammed all your business logic in there. Here's the structure that's worked for us:

app/
  api/
    users/
      route.ts          # GET /api/users, POST /api/users
      [id]/
        route.ts        # GET /api/users/:id, PUT, DELETE
        avatar/
          route.ts      # POST /api/users/:id/avatar

lib/
  api/
    users.ts            # actual business logic lives here
  api-response.ts
  auth.ts

The route file is thin — it handles HTTP concerns (parsing, validation, auth) and delegates to `lib/api/users.ts` for the actual logic. This means your business logic is testable without spinning up an HTTP server, and your route files stay readable.

// lib/api/users.ts — pure business logic
export async function getUserById(id: string) {
  return db.query.users.findFirst({
    where: eq(users.id, id),
    columns: { passwordHash: false }, // never expose this
  });
}

export async function updateUser(
  id: string,
  input: UpdateUserInput
) {
  const [updated] = await db
    .update(users)
    .set({ ...input, updatedAt: new Date() })
    .where(eq(users.id, id))
    .returning();
  return updated;
}

// app/api/users/[id]/route.ts — thin HTTP layer
import { getUserById, updateUser } from '@/lib/api/users';
import { ok, err } from '@/lib/api-response';
import { getAuthSession } from '@/lib/auth';
import { updateUserSchema } from '@/lib/validations/users';

export async function GET(
  _req: Request,
  { params }: { params: { id: string } }
) {
  const session = await getAuthSession();
  if (!session) return err('Unauthorized', 401);

  const user = await getUserById(params.id);
  if (!user) return err('User not found', 404, 'USER_NOT_FOUND');

  return ok(user);
}

export async function PUT(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await getAuthSession();
  if (!session) return err('Unauthorized', 401);
  if (session.user.id !== params.id) return err('Forbidden', 403);

  const body = await req.json().catch(() => null);
  const parsed = updateUserSchema.safeParse(body);
  if (!parsed.success) return err(parsed.error.message, 422);

  const updated = await updateUser(params.id, parsed.data);
  return ok(updated);
}

Validation Belongs at the Edge, Not in Your Business Logic

Your business logic functions should receive already-validated, correctly-typed data. Validation belongs in the route handler (the 'edge' of your system), not three function calls deep. We use Zod for this — it's a bit verbose but the TypeScript inference is worth it.

// lib/validations/users.ts
import { z } from 'zod';

export const updateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().optional(),
  bio: z.string().max(500).optional(),
}).strict(); // .strict() rejects unknown fields — important!

export type UpdateUserInput = z.infer<typeof updateUserSchema>;

// Query param validation — often forgotten
export const paginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

// In your route handler:
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const parsed = paginationSchema.safeParse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
  });

  if (!parsed.success) return err('Invalid query params', 400);
  
  const { page, limit } = parsed.data;
  // Now page and limit are guaranteed numbers
}

`.strict()` on your Zod schemas is one of those things you forget until someone sends `{ name: 'Bob', isAdmin: true }` in a request body and you realize your `db.update(...).set(body)` just promoted Bob to admin. Don't ask us how we know this.

Error Handling — Catch at the Top, Not Everywhere

The worst pattern we see is try/catch in every route handler. You end up with slightly different error messages everywhere, some errors slip through, and the code is ugly. Better to have one place that catches everything:

// lib/with-error-handling.ts
import { err } from './api-response';

type RouteHandler = (req: Request, context: any) => Promise<Response>;

export function withErrorHandling(handler: RouteHandler): RouteHandler {
  return async (req, context) => {
    try {
      return await handler(req, context);
    } catch (error) {
      // Known business errors
      if (error instanceof AppError) {
        return err(error.message, error.statusCode, error.code);
      }

      // Prisma/Drizzle unique constraint violation
      if (isUniqueConstraintError(error)) {
        return err('Resource already exists', 409, 'DUPLICATE');
      }

      // Unknown — log it, return generic message
      console.error('[API Error]', error);
      return err('Internal server error', 500);
    }
  };
}

// Custom error class for intentional errors
export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 400,
    public code?: string
  ) {
    super(message);
  }
}

// Usage:
export const GET = withErrorHandling(async (req, { params }) => {
  const user = await getUserById(params.id);
  if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
  return ok(user);
});

Now your happy-path code reads like happy-path code. Errors bubble up and get handled in one place. When you add Sentry later, you add it in `withErrorHandling` and you're done — not in 30 different catch blocks.

HTTP Status Codes — Use Them, But Don't Overthink It

You don't need to memorize the entire HTTP spec. Just be consistent with the handful of status codes you'll actually use:

  • 200 OK — request worked, here's your data
  • 201 Created — POST succeeded and created a resource (return the new resource in the body)
  • 204 No Content — DELETE succeeded, nothing to return
  • 400 Bad Request — client sent garbage, validation failed
  • 401 Unauthorized — not authenticated (no session)
  • 403 Forbidden — authenticated but not allowed to do this
  • 404 Not Found — resource doesn't exist
  • 409 Conflict — resource already exists (duplicate email, etc.)
  • 422 Unprocessable Entity — request was valid JSON but semantically wrong
  • 429 Too Many Requests — rate limited
  • 500 Internal Server Error — your fault, not theirs

The 401 vs 403 distinction trips people up. 401 means 'I don't know who you are.' 403 means 'I know who you are and you can't do this.' A logged-in user trying to edit someone else's profile is 403, not 401. This matters when the frontend decides what to show — redirect to login (401) vs show 'access denied' (403).

Versioning — Do You Even Need It?

For most SaaS apps and internal APIs, you don't need versioning. If you own both the API and the frontend, just change them together. The classic `/api/v1/` prefix is worth it only if you have external consumers who can't update their clients on your schedule — third-party developers, mobile apps, public APIs.

If you do need versioning, folder-based is cleanest in Next.js:

app/
  api/
    v1/
      users/
        route.ts
    v2/
      users/
        route.ts  # breaking changes live here

For the common case (internal APIs), just add a deprecation warning header and give consumers a migration window. `Deprecation: true` and `Sunset: Sat, 01 Jan 2026 00:00:00 GMT` are actual HTTP headers for this. Probably overkill for your indie app, but good to know they exist.

Middleware for Auth — Don't Repeat Yourself Across Routes

Checking auth in every single route handler gets old fast. For whole sections of your API that require auth, use Next.js middleware:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getSessionFromRequest } from '@/lib/auth';

export async function middleware(request: NextRequest) {
  // Protect all /api routes except auth endpoints
  if (
    request.nextUrl.pathname.startsWith('/api') &&
    !request.nextUrl.pathname.startsWith('/api/auth')
  ) {
    const session = await getSessionFromRequest(request);

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

    // Forward user info to route handlers via headers
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-user-id', session.user.id);
    requestHeaders.set('x-user-role', session.user.role);

    return NextResponse.next({ request: { headers: requestHeaders } });
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/api/:path*'],
};

// In your route handler, read the forwarded user:
export async function GET(req: Request) {
  const userId = req.headers.get('x-user-id')!;
  // No auth check needed — middleware handled it
}

One caveat: passing user info via headers like this is fine for user ID and role, but don't put sensitive data in headers. And make sure these headers can't be set by external requests — they should only be set by your middleware.

For more granular permission checks (can this user edit *this specific* resource), those still live in individual route handlers. Middleware handles the 'are you logged in at all' question.

A Word on Server Actions vs Route Handlers

We've written about this before, but the short version: use route handlers when you need a real HTTP API (external consumers, webhooks, mobile apps, third-party integrations). Use Server Actions for form mutations that only your own React components will call. Don't build a REST API with Server Actions just because they're new and exciting — they're not designed for that use case and the error handling story is worse.

If you're building a SaaS and need both a web app and a public API, the pattern that works is: Server Actions for your web UI's mutations, Route Handlers for your API endpoints. They can share the same `lib/api/` business logic functions — that's the whole point of the separation.

The best API design is boring. Consistent naming, consistent response shapes, predictable status codes. Save your creativity for the product, not the plumbing.

Most of the patterns here are baked into the peal.dev templates — the response envelope, the validation setup, the middleware auth layer. Worth looking at if you want to skip the 'figure out conventions' phase and go straight to building your actual product.

The single most impactful thing you can do for your API's long-term maintainability is decide on your response shape on day one and never break it. Everything else — file structure, error handling, versioning — you can refactor later. But changing your response envelope after you have a frontend consuming it is a bad time. We've been there. Just pick something consistent now and stick with it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders