Skip to main content
Use this guide as your blueprint for building API routes under src/app/api/*. It covers input validation with Zod, optional authentication guards with Supabase, consistent responses, security, and testing. For a concrete, fully implemented example, see Contact Form API.
To decide between protecting at the edge (middleware) vs inside the route (server guard), see Routing & Middleware. In general, use route-level guards for most APIs, and reserve middleware for global page gating or cross‑cutting concerns.

Route structure

Each endpoint lives in its own folder with a route.ts file:
src/app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const contactSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  company: z.string().min(2),
  message: z.string().min(10),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const data = contactSchema.parse(body);
  // ... your logic ...
  return NextResponse.json({ success: true });
}
Prefer one responsibility per route. Split complex features into multiple endpoints with clear names.

Validation with Zod

  1. Define a schema near the top of your file:
src/app/api/example/route.ts
const schema = z.object({
  name: z.string().min(1, "Name is required"),
});
  1. Parse and handle validation errors consistently:
src/app/api/contact/route.ts
try {
  const body = await request.json();
  const data = contactSchema.parse(body);
  // use `data`
} catch (error) {
  if (error instanceof z.ZodError) {
    return NextResponse.json(
      { success: false, message: "Validation failed", errors: error.issues },
      { status: 400 }
    );
  }
  return NextResponse.json(
    { success: false, message: "An error occurred while processing your request" },
    { status: 500 }
  );
}
The error shape above matches the Contact endpoint, keeping your API responses predictable across routes.

Handling non-JSON payloads

Some integrations (Stripe Checkout, webhooks, file uploads) submit data as application/x-www-form-urlencoded or multipart/form-data. Read those bodies with request.formData() before passing values into your schema:
src/app/api/checkout_sessions/route.ts
const formData = await request.formData();
const price_id = formData.get("price_id");

if (!price_id || price_id === "undefined") {
  return NextResponse.json(
    { error: "Valid price ID is required" },
    { status: 400 }
  );
}

const lineItems = checkoutSchema.parse({ price_id });
When you only need a few fields from formData, pull them out explicitly (as above) and run them through the same Zod schema. This keeps validation identical regardless of transport format.

Optional: Authentication guard (Supabase)

For private endpoints, use the server client to read the session and return 401 when missing:
src/app/api/private/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { z } from "zod";

export async function POST(request: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
  }

  // ... continue with validation and logic
  return NextResponse.json({ success: true });
}
Avoid leaking sensitive details in error messages. Return generic messages for 401/403.

Consistent responses

  • Success (200/201): { success: true, ... }
  • Validation error (400): { success: false, message: "Validation failed", errors: [...] }
  • Unauthorized (401/403): { success: false, message: "Unauthorized" }
  • Server error (500): { success: false, message: "An error occurred while processing your request" }
Keep the top-level fields stable so clients can handle errors uniformly.

Security checklist

  • Accept only needed methods; return 405 for others.
  • Validate and sanitize all inputs (Zod).
  • Do not log secrets or PII. Redact when necessary.
  • Use auth guards for protected resources.
  • Avoid open redirects and untrusted origins.
src/app/api/contact/route.ts
export async function GET() {
  return NextResponse.json(
    { success: false, message: "Method not allowed" },
    { status: 405 }
  );
}

Testing locally

# Send a local request
curl -X POST 'http://localhost:3000/api/contact' \
  -H 'Content-Type: application/json' \
  -d '{"firstName":"Jane","lastName":"Doe","email":"jane@example.com","company":"Acme","message":"Hello"}'
// Expected response
{ "success": true, "message": "Thank you for your message! We'll get back to you soon." }
Use Contact Form API as a reference implementation for validation, error handling, and method handling.