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
- 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"),
});
- 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.