DEV Community

Anas Sheikh
Anas Sheikh

Posted on

How I Structure Next.js 15 API Routes for Production SaaS Apps

Most Next.js API route tutorials show you the basics.

Nobody shows you how to structure them for a real production app.

Here's exactly how I do it after building multiple SaaS products.


The Problem With Basic API Routes

Most tutorials show this:

// app/api/users/route.ts
export async function GET() {
  const users = await db.users.findMany();
  return Response.json(users);
}
Enter fullscreen mode Exit fullscreen mode

This works. But it breaks down fast in production:

  • No input validation
  • No error handling
  • No authentication
  • No consistent response format
  • No TypeScript types

1. Consistent Response Format

First thing I do in every project โ€” create a response helper:

// lib/api-response.ts
export function successResponse(data: unknown, status = 200) {
  return Response.json(
    { success: true, data },
    { status }
  );
}

export function errorResponse(message: string, status = 400) {
  return Response.json(
    { success: false, error: message },
    { status }
  );
}
Enter fullscreen mode Exit fullscreen mode

Now every route returns the same shape. Your frontend always knows what to expect.


2. Input Validation with Zod

Never trust incoming data. Ever.

// app/api/users/route.ts
import { z } from 'zod';
import { successResponse, errorResponse } from '@/lib/api-response';

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

export async function POST(request: Request) {
  try {
    const body = await request.json();

    // Validate input
    const result = CreateUserSchema.safeParse(body);
    if (!result.success) {
      return errorResponse(result.error.errors[0].message, 400);
    }

    const { name, email, role } = result.data;

    const user = await db.users.create({ data: { name, email, role } });
    return successResponse(user, 201);

  } catch (error) {
    return errorResponse('Internal server error', 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

safeParse never throws โ€” it returns a result object you can check. Much cleaner than try/catch around parse.


3. Middleware for Authentication

Don't repeat auth logic in every route:

// lib/auth-middleware.ts
import { NextRequest } from 'next/server';

export async function withAuth(
  request: NextRequest,
  handler: (req: NextRequest, userId: string) => Promise<Response>
): Promise<Response> {

  const token = request.headers.get('authorization')?.replace('Bearer ', '');

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

  try {
    const payload = verifyJWT(token); // your JWT verify function
    return handler(request, payload.userId);
  } catch {
    return Response.json(
      { success: false, error: 'Invalid token' },
      { status: 401 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in any route:

// app/api/dashboard/route.ts
export async function GET(request: NextRequest) {
  return withAuth(request, async (req, userId) => {
    const data = await db.dashboard.findFirst({ where: { userId } });
    return successResponse(data);
  });
}
Enter fullscreen mode Exit fullscreen mode

One line of auth in every protected route. Clean.


4. Rate Limiting

Simple in-memory rate limiter for API routes:

// lib/rate-limit.ts
const requests = new Map<string, { count: number; resetTime: number }>();

export function rateLimit(ip: string, limit = 10, windowMs = 60000): boolean {
  const now = Date.now();
  const record = requests.get(ip);

  if (!record || now > record.resetTime) {
    requests.set(ip, { count: 1, resetTime: now + windowMs });
    return true; // allowed
  }

  if (record.count >= limit) {
    return false; // blocked
  }

  record.count++;
  return true; // allowed
}
Enter fullscreen mode Exit fullscreen mode

Usage:

export async function POST(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown';

  if (!rateLimit(ip, 10, 60000)) {
    return errorResponse('Too many requests', 429);
  }

  // rest of your route
}
Enter fullscreen mode Exit fullscreen mode

Note: use Redis for production multi-instance apps. This works fine for single-server deployments.


5. Full Route Example

Putting it all together:

// app/api/posts/route.ts
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/auth-middleware';
import { rateLimit } from '@/lib/rate-limit';
import { successResponse, errorResponse } from '@/lib/api-response';

const CreatePostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
  published: z.boolean().default(false),
});

export async function POST(request: NextRequest) {
  // Rate limit
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
  if (!rateLimit(ip)) {
    return errorResponse('Too many requests', 429);
  }

  // Auth + handler
  return withAuth(request, async (req, userId) => {
    try {
      const body = await req.json();

      // Validate
      const result = CreatePostSchema.safeParse(body);
      if (!result.success) {
        return errorResponse(result.error.errors[0].message);
      }

      // Create
      const post = await db.posts.create({
        data: { ...result.data, authorId: userId }
      });

      return successResponse(post, 201);

    } catch (error) {
      console.error('Create post error:', error);
      return errorResponse('Internal server error', 500);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Every production API route I write follows this exact pattern.


Summary

Pattern Why
Consistent response format Frontend always knows what to expect
Zod validation Never trust input data
Auth middleware No repeated auth logic
Rate limiting Protect against abuse
Try/catch everything Never expose stack traces

I use these patterns in all my Next.js templates.

See them in action:

Get the templates: https://pixelanas.gumroad.com

What patterns do you use in your API routes? Drop them below ๐Ÿ‘‡


Anas โ€” full-stack Next.js developer. Building SaaS products and premium templates. X: @pixelanas

Top comments (0)