Next.js API routes are easy to get started with and easy to build wrong at scale. This guide covers the patterns that distinguish production-ready API routes from ones that cause problems later.
Route Handlers vs Pages API Routes
Pages Router API routes live in pages/api/. App Router Route Handlers live in app/api/ as route.ts files. They are not interchangeable.
Route Handlers are the current standard for new projects. They support the Web API Response and Request objects natively, support streaming responses, and integrate naturally with App Router conventions.
If you are on an existing Pages Router project, there is no urgency to migrate. Both work. Mixed usage (some in Pages Router, some in App Router) is possible but adds cognitive overhead with no benefit unless you specifically need App Router features.
The import difference:
// Pages Router -- /pages/api/projects.ts
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ projects: [] });
}
// App Router Route Handler -- /app/api/projects/route.ts
export async function GET() {
return Response.json({ projects: [] });
}
Authentication Pattern
Every protected route needs an authentication check at the top. The pattern:
import { getCurrentUser } from "@/lib/auth/utils";
export async function GET(request: Request) {
const user = await getCurrentUser();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// user is typed and available
}
getCurrentUser() reads the auth cookie, verifies the JWT, and returns the user object or null. This is the single check that gates every protected route.
For routes that need organization-scoped data, extract the organization ID from the request and verify the user belongs to that organization before querying:
export async function GET(
request: Request,
{ params }: { params: Promise<{ orgId: string }> }
) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { orgId } = await params;
// Verify user is member of orgId before querying
const isMember = user.organizations.includes(orgId);
if (!isMember) return Response.json({ error: "Forbidden" }, { status: 403 });
// Proceed with org-scoped query
}
Never trust organization IDs from the request body without verifying membership. Always filter queries by organization_id.