GetLaunchpad
Back to blog
6 min read

Next.js API route best practices for production SaaS

Most Next.js API routes ship without authentication checks, input validation, or rate limiting. Here's how to add Clerk auth, Zod validation, Upstash rate limiting, consistent error shapes, and proper HTTP status codes to every route handler.

API routes in Next.js App Router are powerful — but most developers ship them without authentication checks, input validation, or rate limiting. That works fine until it doesn't: one malicious request or a misconfigured handler can expose user data, rack up database costs, or flood your Stripe account with junk sessions. Here's how to build API routes that are safe and production-ready from day one.

1. Always check authentication first

The first line of any protected route handler should be an auth check. With Clerk, that means calling auth() from @clerk/nextjs/server before you do anything else — before reading the request body, before touching the database, before anything.

// app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

export async function GET() {
  const { userId } = await auth();
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // safe to proceed
}

If you read the body first and then find the user is unauthorized, you've wasted a parse operation and potentially logged sensitive data. Auth first, always.

2. Validate input with Zod before touching the database

Never trust request bodies. Even if the caller is your own frontend, validate every field before it reaches your database. Zod is the standard choice in the TypeScript ecosystem — it gives you runtime validation and inferred types in one step.

import { z } from "zod";

const createProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
});

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const body = await req.json();
  const parsed = createProjectSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid input", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const { name, description } = parsed.data;
  // now safe to write to the database
}

Use safeParse instead of parse so you can return a clean 400 response rather than letting an unhandled exception propagate.

3. Rate limit with Upstash sliding window

Without rate limiting, any authenticated user (or anyone who leaks a token) can hammer your API. The sliding window algorithm from Upstash Redis is the right default: it smooths out burst traffic while still enforcing a per-window cap.

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "60 s"),
});

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { success } = await ratelimit.limit(userId);
  if (!success) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  // proceed
}

Key by userId so limits are per user, not per IP. IP-based limits are easy to bypass with proxies; user-based limits are not.

4. Use consistent error response shapes

Every error response from every route handler should look the same. If some routes return { message: "..." } and others return { error: "..." }, your frontend ends up with branching error-handling logic that's impossible to maintain. Pick one shape and stick to it across your entire API:

// Good — consistent shape everywhere
return NextResponse.json({ error: "Project not found" }, { status: 404 });
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
return NextResponse.json({ error: "Invalid input" }, { status: 400 });

// Bad — inconsistent shapes the frontend has to special-case
return NextResponse.json({ message: "not found" }, { status: 404 });
return new Response("Forbidden", { status: 403 });

A shared helper makes this easy to enforce:

export function apiError(message: string, status: number) {
  return NextResponse.json({ error: message }, { status });
}

5. Never leak stack traces or internal errors to the client

A raw exception message can expose your database schema, your internal service names, or the exact query that failed. Always catch at the top level and return a generic error to the client, while logging the real error on the server:

export async function POST(req: Request) {
  try {
    const { userId } = await auth();
    if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

    // ... handler logic

    return NextResponse.json({ success: true }, { status: 201 });
  } catch (err) {
    console.error("[POST /api/projects]", err);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

The client gets a safe 500 response. Your logs get the full error with the route prefix so you can grep for it.

6. Log with console.error and add Sentry for production

console.error is the minimum — it appears in your Vercel function logs and gives you something to search. But for production, you want error tracking that captures context, creates issues, and alerts you. Add Sentry with @sentry/nextjs and call Sentry.captureException(err) in your catch block:

import * as Sentry from "@sentry/nextjs";

} catch (err) {
  console.error("[POST /api/projects]", err);
  Sentry.captureException(err);
  return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}

This gives you stack traces, breadcrumbs, and user context in Sentry — without exposing any of that to the API caller.

7. Return the right HTTP status codes

HTTP status codes communicate intent. Using them correctly means your frontend, monitoring tools, and API clients can respond intelligently without parsing error message strings. Here's the cheat sheet for SaaS API routes:

200 OK              — successful GET or PATCH
201 Created         — successful POST that created a resource
400 Bad Request     — invalid input (failed Zod validation)
401 Unauthorized    — not signed in (no session)
403 Forbidden       — signed in but not allowed (wrong user, missing role)
404 Not Found       — resource doesn't exist or the caller shouldn't know it exists
429 Too Many Requests — rate limit exceeded
500 Internal Server Error — unhandled exception

The distinction between 401 and 403 matters: 401 means “who are you?” and should trigger a redirect to sign-in. 403 means “I know who you are, but no.” The distinction between 404 and 403 also matters for security: returning 403 when a resource belongs to another user confirms that it exists. Returning 404 is safer.


Every pattern in this guide — auth first, Zod validation, Upstash rate limiting, consistent error shapes, and Sentry integration — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and start building your product today.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles