API Design in Next.js
Building Production-Grade Backends Without Leaving Your Framework
LinkedIn Hook
"We had 14 microservices, 3 API gateways, and a backend team of 6 engineers — just to serve a CRUD app with 12 endpoints."
Then we moved everything into Next.js Route Handlers. The 14 services became 14 files. The 3 gateways became middleware. The backend team? They became full-stack developers who shipped twice as fast.
But here's the thing most teams get wrong: moving your API into Next.js doesn't mean you can skip API design. You still need validation, error handling, CORS, rate limiting, and proper database access.
The difference is that Next.js gives you a framework to do all of it in one place — if you know the patterns.
In Lesson 6.2, you'll learn how to design production-grade APIs inside Next.js: RESTful patterns, Zod validation, structured error responses, CORS, rate limiting, and when Next.js actually replaces a separate backend.
Read the full lesson -> [link]
#NextJS #APIDesign #REST #FullStack #WebDevelopment #InterviewPrep #BackendDevelopment
What You'll Learn
- How to design RESTful APIs using Next.js Route Handlers with proper HTTP methods and status codes
- Request validation with Zod — catching bad data before it touches your database
- Structured error responses that frontend developers actually enjoy consuming
- CORS handling for when your API serves external clients
- Rate limiting strategies to protect your endpoints from abuse
- Connecting to databases with Prisma and Drizzle inside Route Handlers
- When Next.js API routes genuinely replace a separate backend — and when they don't
The Post Office Analogy — Designing API Routes
Think of your Next.js API as a well-run post office.
Route Handlers are the service counters. Each counter handles a specific type of request: one for sending packages (POST), one for tracking packages (GET), one for updating delivery addresses (PUT), one for cancelling shipments (DELETE). You don't have one counter doing everything — you organize by purpose.
Request validation is the clerk checking your package before accepting it. Is the address formatted correctly? Is the package within the weight limit? Does it contain prohibited items? The clerk rejects invalid packages at the counter, before they ever enter the sorting system.
Error responses are the standardized rejection slips. They don't just say "rejected." They say "rejected because the zip code is invalid" with a code the sender can use to fix the problem.
Rate limiting is the "take a number" system. If one person tries to send 1,000 packages in a minute, they get told to slow down — so the other customers can be served too.
CORS is the international shipping policy. Packages from certain countries (origins) are accepted; others are blocked. The policy is checked before the package is even opened.
Your Next.js API works the same way. Every layer serves a purpose, and skipping any one of them means problems in production.
RESTful Patterns with Route Handlers
A well-designed REST API in Next.js follows predictable conventions. Each resource gets its own route file, and HTTP methods map to CRUD operations.
The File Structure
app/
api/
users/
route.ts // GET /api/users (list), POST /api/users (create)
[id]/
route.ts // GET /api/users/:id, PUT /api/users/:id, DELETE /api/users/:id
posts/
route.ts // GET /api/posts, POST /api/posts
[id]/
route.ts // GET /api/posts/:id, PUT /api/posts/:id, DELETE /api/posts/:id
comments/
route.ts // GET /api/posts/:id/comments (nested resource)
Collection Route — List and Create
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users — Retrieve a list of users with pagination
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Clamp limit to prevent abuse (never allow more than 100 per page)
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), 100);
const offset = (safePage - 1) * safeLimit;
// In a real app, this would be a database query
const users = await db.user.findMany({
skip: offset,
take: safeLimit,
select: { id: true, name: true, email: true, createdAt: true },
});
const total = await db.user.count();
return NextResponse.json({
data: users,
pagination: {
page: safePage,
limit: safeLimit,
total,
totalPages: Math.ceil(total / safeLimit),
},
});
}
// POST /api/users — Create a new user
export async function POST(request: NextRequest) {
const body = await request.json();
// Validation happens here (we'll cover Zod next)
const user = await db.user.create({
data: {
name: body.name,
email: body.email,
},
});
// 201 Created — the correct status code for resource creation
return NextResponse.json({ data: user }, { status: 201 });
}
Individual Resource Route — Read, Update, Delete
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
type Params = { params: Promise<{ id: string }> };
// GET /api/users/:id — Retrieve a single user
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
const user = await db.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, createdAt: true },
});
if (!user) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: `User ${id} not found` } },
{ status: 404 }
);
}
return NextResponse.json({ data: user });
}
// PUT /api/users/:id — Update a user (full replacement)
export async function PUT(request: NextRequest, { params }: Params) {
const { id } = await params;
const body = await request.json();
try {
const user = await db.user.update({
where: { id },
data: { name: body.name, email: body.email },
});
return NextResponse.json({ data: user });
} catch (error) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: `User ${id} not found` } },
{ status: 404 }
);
}
}
// DELETE /api/users/:id — Delete a user
export async function DELETE(request: NextRequest, { params }: Params) {
const { id } = await params;
try {
await db.user.delete({ where: { id } });
// 204 No Content — successful deletion with no response body
return new NextResponse(null, { status: 204 });
} catch (error) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: `User ${id} not found` } },
{ status: 404 }
);
}
}
Key interview point: Notice the consistent response shape. Successful responses use { data: ... }. Error responses use { error: { code, message } }. This consistency makes the API predictable for frontend consumers.
Request Validation with Zod
Never trust incoming data. Every request body, query parameter, and path parameter should be validated before processing. Zod is the standard choice in the Next.js ecosystem.
// lib/validations/user.ts
import { z } from 'zod';
// Define the shape of valid user input
export const createUserSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be at most 100 characters')
.trim(),
email: z
.string()
.email('Invalid email address')
.toLowerCase(),
role: z
.enum(['user', 'admin', 'moderator'])
.default('user'),
});
// Schema for updates — all fields optional (partial update)
export const updateUserSchema = createUserSchema.partial();
// Schema for query parameters
export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
});
// TypeScript types inferred from schemas — single source of truth
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type ListUsersQuery = z.infer<typeof listUsersQuerySchema>;
Using Validation in Route Handlers
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createUserSchema, listUsersQuerySchema } from '@/lib/validations/user';
// Reusable validation helper that formats Zod errors into API responses
function validateBody<T>(schema: z.ZodSchema<T>, data: unknown) {
const result = schema.safeParse(data);
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
return { success: false as const, errors };
}
return { success: true as const, data: result.data };
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
// Convert searchParams to a plain object for Zod parsing
const queryObject = Object.fromEntries(searchParams.entries());
const validation = validateBody(listUsersQuerySchema, queryObject);
if (!validation.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid query parameters',
details: validation.errors,
},
},
{ status: 400 }
);
}
const { page, limit, search, sortBy, order } = validation.data;
// Proceed with validated, typed data...
}
export async function POST(request: NextRequest) {
let body: unknown;
// Safely parse JSON — handle malformed request bodies
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_JSON', message: 'Request body is not valid JSON' } },
{ status: 400 }
);
}
const validation = validateBody(createUserSchema, body);
if (!validation.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: validation.errors,
// Example response:
// details: [
// { field: "name", message: "Name must be at least 2 characters" },
// { field: "email", message: "Invalid email address" }
// ]
},
},
{ status: 400 }
);
}
// validation.data is fully typed as CreateUserInput
const user = await db.user.create({ data: validation.data });
return NextResponse.json({ data: user }, { status: 201 });
}
Structured Error Responses
A consistent error format across your entire API saves hours of frontend debugging. Define one shape and use it everywhere.
// lib/api-error.ts
// Standard error response shape used by every endpoint
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
}
}
// Pre-defined error factories for common cases
export const Errors = {
notFound: (resource: string, id: string) =>
new ApiError(404, 'NOT_FOUND', `${resource} with id '${id}' not found`),
validation: (details: { field: string; message: string }[]) =>
new ApiError(400, 'VALIDATION_ERROR', 'Request validation failed', details),
unauthorized: () =>
new ApiError(401, 'UNAUTHORIZED', 'Authentication required'),
forbidden: () =>
new ApiError(403, 'FORBIDDEN', 'You do not have permission to perform this action'),
conflict: (message: string) =>
new ApiError(409, 'CONFLICT', message),
rateLimited: () =>
new ApiError(429, 'RATE_LIMITED', 'Too many requests. Please try again later.'),
internal: () =>
new ApiError(500, 'INTERNAL_ERROR', 'An unexpected error occurred'),
};
// Unified error handler that wraps any route handler
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
...(error.details ? { details: error.details } : {}),
},
},
{ status: error.statusCode }
);
}
// Unexpected errors — log the real error, return a generic message
console.error('Unhandled API error:', error);
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 }
);
}
Using the Error Handler in Routes
// app/api/users/[id]/route.ts
import { Errors, handleApiError } from '@/lib/api-error';
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) throw Errors.notFound('User', id);
return NextResponse.json({ data: user });
} catch (error) {
return handleApiError(error);
}
}
This pattern means every endpoint returns errors in the exact same shape. The frontend can write one error handler and trust that response.error.code and response.error.message always exist.
CORS Handling
When your Next.js API serves clients from different origins (a mobile app, a separate frontend, or third-party integrations), you need CORS headers.
// lib/cors.ts
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://admin.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
export function getCorsHeaders(origin: string | null) {
// Check if the requesting origin is allowed
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
};
}
// app/api/users/route.ts
import { getCorsHeaders } from '@/lib/cors';
// Handle preflight requests — browsers send OPTIONS before cross-origin POST/PUT/DELETE
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin');
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
});
}
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin');
const users = await db.user.findMany();
return NextResponse.json(
{ data: users },
{ headers: getCorsHeaders(origin) }
);
}
CORS via Middleware (Global Approach)
For APIs that need CORS on every route, use Next.js middleware instead of adding headers to each handler:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Only apply CORS to API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
const origin = request.headers.get('origin') || '';
// Handle preflight
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
// Add CORS headers to actual responses
const response = NextResponse.next();
response.headers.set('Access-Control-Allow-Origin', origin);
return response;
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
Rate Limiting
Without rate limiting, a single bad actor can overwhelm your API. Here is a simple in-memory rate limiter — good enough for single-server deployments and interviews:
// lib/rate-limit.ts
// In-memory store — for production, use Redis or an external service
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
export function rateLimit(
identifier: string,
maxRequests: number = 60,
windowMs: number = 60 * 1000 // 1 minute
): { allowed: boolean; remaining: number; resetIn: number } {
const now = Date.now();
const record = rateLimitStore.get(identifier);
// Clean up expired entries
if (record && now > record.resetTime) {
rateLimitStore.delete(identifier);
}
const current = rateLimitStore.get(identifier);
if (!current) {
// First request in this window
rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs });
return { allowed: true, remaining: maxRequests - 1, resetIn: windowMs };
}
if (current.count >= maxRequests) {
// Limit exceeded
return {
allowed: false,
remaining: 0,
resetIn: current.resetTime - now,
};
}
// Increment counter
current.count += 1;
return {
allowed: true,
remaining: maxRequests - current.count,
resetIn: current.resetTime - now,
};
}
// app/api/users/route.ts
import { rateLimit } from '@/lib/rate-limit';
export async function GET(request: NextRequest) {
// Use IP address as the rate limit identifier
const ip = request.headers.get('x-forwarded-for') || 'anonymous';
const { allowed, remaining, resetIn } = rateLimit(ip, 60, 60_000);
if (!allowed) {
return NextResponse.json(
{ error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil(resetIn / 1000)),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Proceed with the request...
const users = await db.user.findMany();
return NextResponse.json(
{ data: users },
{
headers: {
'X-RateLimit-Remaining': String(remaining),
},
}
);
}
Interview insight: An in-memory Map works for single-instance deployments. In production with multiple server instances (Vercel serverless functions, for example), each instance has its own Map — so the rate limit isn't shared. For distributed rate limiting, use Redis with a sliding window algorithm (e.g., Upstash Rate Limit or ioredis).
Connecting to Databases — Prisma and Drizzle
Prisma Setup
Prisma is the most widely adopted TypeScript ORM. Here is how to connect it to Next.js Route Handlers properly:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
// Prevent multiple Prisma instances in development
// Next.js hot-reloads modules, which creates a new PrismaClient every time
// This causes "too many database connections" errors
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// app/api/posts/route.ts
import { prisma } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const posts = await prisma.post.findMany({
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 20,
});
return NextResponse.json({ data: posts });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
authorId: body.authorId,
},
});
return NextResponse.json({ data: post }, { status: 201 });
}
Drizzle Setup
Drizzle is the SQL-first alternative — lighter, faster, and closer to raw SQL:
// lib/db-drizzle.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
// lib/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: uuid('author_id').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// app/api/posts/route.ts (Drizzle version)
import { db } from '@/lib/db-drizzle';
import { posts, users } from '@/lib/schema';
import { eq, desc } from 'drizzle-orm';
export async function GET() {
// Drizzle query — reads like SQL, fully typed
const result = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
createdAt: posts.createdAt,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.createdAt))
.limit(20);
return NextResponse.json({ data: result });
}
Prisma vs Drizzle — interview talking point: Prisma offers a higher-level API, automatic migrations, and a visual studio (Prisma Studio). Drizzle is closer to raw SQL, produces smaller bundles, and has zero runtime overhead from query translation. Choose Prisma for rapid prototyping and complex relations. Choose Drizzle for performance-critical APIs where you want SQL-level control.
When Does Next.js Replace a Separate Backend?
This is one of the most common interview discussion questions. The answer is nuanced.
Next.js API Is Sufficient When:
| Scenario | Why It Works |
|---|---|
| CRUD apps (dashboards, admin panels, blogs) | Standard database operations with simple business logic |
| BFF (Backend for Frontend) | Aggregating multiple external APIs into one response for the frontend |
| Auth endpoints (login, signup, session management) | Libraries like NextAuth.js/Auth.js are built for this |
| Webhook receivers | Handling Stripe webhooks, GitHub webhooks, etc. |
| File uploads (small to medium) | Route Handlers accept FormData natively |
| Prototypes and MVPs | Ship faster by eliminating a separate backend project |
You Still Need a Separate Backend When:
| Scenario | Why Next.js Falls Short |
|---|---|
| Long-running processes (video encoding, ML inference) | Serverless functions have execution time limits (10-60s on Vercel) |
| WebSocket connections | Next.js Route Handlers are request-response, not persistent connections |
| CPU-intensive computation | Blocks the Node.js event loop, affecting all users |
| Multiple non-JS consumers (mobile apps, IoT devices, partner APIs) | A dedicated API service is easier to version, document, and scale independently |
| Complex domain logic (banking, healthcare compliance) | Requires domain-driven design patterns that benefit from a dedicated service architecture |
| Event-driven architectures (message queues, event sourcing) | Needs persistent processes listening to queues — not request-triggered |
The Hybrid Pattern
The most practical approach for many teams: use Next.js Route Handlers as a BFF (Backend for Frontend) that sits in front of your core services.
Browser --> Next.js Route Handlers (BFF) --> Core Services (Go, Python, etc.)
| |
|-- Validation |-- Business logic
|-- Session management |-- Heavy computation
|-- Response formatting |-- Database writes
|-- Caching |-- Message queues
// app/api/dashboard/route.ts — BFF example
// Aggregates data from multiple backend services into one response
export async function GET(request: NextRequest) {
const session = await getSession(request);
if (!session) return Errors.unauthorized();
// Parallel fetches to multiple backend services
const [profile, analytics, notifications] = await Promise.all([
fetch(`${USERS_SERVICE}/users/${session.userId}`).then((r) => r.json()),
fetch(`${ANALYTICS_SERVICE}/stats/${session.userId}`).then((r) => r.json()),
fetch(`${NOTIFICATIONS_SERVICE}/unread/${session.userId}`).then((r) => r.json()),
]);
// Return one aggregated response instead of making the frontend call 3 APIs
return NextResponse.json({
data: {
user: profile,
stats: analytics,
unreadCount: notifications.count,
},
});
}
Common Mistakes
-
Skipping request validation. Trusting
request.json()output and passing it directly to your database is an injection risk and a crash waiting to happen. Always validate with Zod (or similar) before processing any input. The five minutes you spend writing a schema saves hours of debugging malformed data in production. -
Inconsistent error response shapes. If one endpoint returns
{ error: "not found" }and another returns{ message: "Not Found", statusCode: 404 }, the frontend team has to write special handling for every endpoint. Define one error shape and enforce it everywhere. -
Storing the Prisma client in module scope without the globalThis pattern. In development, Next.js hot-reloads modules on every change. Each reload creates a new
PrismaClient, opening new database connections. After a few saves you hit the connection limit. TheglobalThispattern (shown above) reuses the same instance across reloads. -
Using in-memory rate limiting on serverless. Each serverless function instance has its own memory. A user hitting different instances bypasses the rate limit entirely. Use Redis-backed rate limiting (Upstash, ioredis) for any serverless deployment.
-
Building a full backend in Next.js when the requirements call for a separate service. If you need WebSockets, long-running jobs, or your API serves multiple non-web clients, forcing everything into Next.js Route Handlers creates more problems than it solves. Use Next.js as a BFF and let specialized services handle specialized work.
Interview Questions
1. How would you structure a RESTful API in Next.js App Router? Walk me through the file organization and HTTP method mapping.
(Covered in the RESTful Patterns section — file-based routing maps to resources, exported functions map to HTTP methods, collection routes vs individual resource routes.)
2. How do you validate incoming request data in a Next.js Route Handler? What happens when validation fails?
(Covered in the Zod Validation section — use safeParse, return 400 with structured error details including field names and messages, never let unvalidated data reach the database.)
3. Explain how you would handle CORS in a Next.js API that needs to serve both a same-origin frontend and an external mobile app.
(Covered in the CORS section — check the Origin header against an allowlist, return appropriate CORS headers, handle OPTIONS preflight requests, optionally use middleware for global CORS.)
4. Your Next.js app works fine in development but crashes in production with "too many database connections." What is likely causing this, and how do you fix it?
In development, Next.js hot-reloads modules on every file change. Each reload instantiates a new
PrismaClient, which opens a new connection pool. TheglobalThispattern stores the client on the global object so it survives reloads. In production, the issue is more likely caused by serverless function scaling — each function instance creates its own connection pool. The fix is to use a connection pooler like PgBouncer, Prisma Accelerate, or Supabase's built-in pooler to share connections across instances.
5. A startup founder asks you: "Can we just use Next.js as our entire backend?" What factors would you evaluate before answering?
(Covered in the "When Does Next.js Replace a Separate Backend" section — evaluate CRUD complexity, real-time requirements, consumer diversity, long-running processes, computation intensity, and team expertise. For most CRUD apps and MVPs, yes. For complex domain logic or multi-consumer APIs, use Next.js as a BFF layer.)
Quick Reference -- Cheat Sheet
| Concept | Key Point |
|---|---|
| Route structure | app/api/resource/route.ts for collections, app/api/resource/[id]/route.ts for items |
| HTTP methods | Export GET, POST, PUT, DELETE functions from route.ts |
| Validation | Use Zod safeParse — never trust raw input |
| Error format | Consistent { error: { code, message, details? } } on every endpoint |
| Status codes | 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, 429 Rate Limited |
| CORS | Handle OPTIONS preflight, set Access-Control-Allow-* headers |
| Rate limiting | In-memory for single-server, Redis for serverless/distributed |
| Prisma in Next.js | Use globalThis pattern to prevent connection leaks in dev |
| Drizzle vs Prisma | Drizzle = SQL-first, lighter; Prisma = higher-level, richer tooling |
| Next.js as backend | Great for CRUD, BFF, auth, webhooks; not for WebSockets, queues, long jobs |
+-----------------------------------------------+
| Next.js API Design Mental Model |
+-----------------------------------------------+
| |
| Request comes in |
| | |
| v |
| 1. CORS check (is this origin allowed?) |
| | |
| v |
| 2. Rate limit check (too many requests?) |
| | |
| v |
| 3. Auth check (is the user authenticated?) |
| | |
| v |
| 4. Validate input (Zod schema) |
| | |
| v |
| 5. Business logic + database query |
| | |
| v |
| 6. Return structured response |
| { data: ... } or { error: { code, msg } } |
| |
+-----------------------------------------------+
Previous: Lesson 6.1 -- Route Handlers Next: Lesson 6.3 -- Authentication Patterns
This is Lesson 6.2 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.