Backend and API Development
Server vs. Client: The Mental Model
When you open a website, two computers are involved: your browser (the client) and a remote machine (the server). Understanding what runs where is fundamental to building web apps, even when AI writes most of your code.
The client (browser) handles what users see and interact with: rendering HTML, responding to clicks, playing animations. JavaScript running in the browser has access to the DOM, localStorage, and the user's screen — but it cannot access databases, file systems, or secret API keys.
The server handles what needs to be secure or computationally heavy: reading from databases, processing payments, sending emails, validating data. Server code has access to environment variables, databases, and secret keys — but it cannot directly manipulate the user's screen.
Next.js Server Components vs. Client Components
Next.js makes the server-client divide explicit. By default, components in the App Router are Server Components — they run on the server, can access databases directly, and never send their code to the browser.
When a component needs interactivity (clicks, state, effects), you add the "use client" directive at the top of the file:
"use client"
import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}
The rule of thumb: Keep components on the server unless they need browser APIs (useState, useEffect, onClick handlers). Server Components are faster, more secure, and reduce the JavaScript sent to the browser.
When prompting AI, be explicit: "This component needs to be a Client Component because it uses useState" or "Keep this as a Server Component — it only displays data from the database."
Next.js API Routes
API routes let you build server endpoints inside your Next.js app. They live in the app/api/ directory and handle HTTP requests.
Creating an Endpoint
// app/api/tasks/route.ts
import { NextResponse } from "next/server"
export async function GET() {
const tasks = await db.task.findMany()
return NextResponse.json(tasks)
}
export async function POST(request: Request) {
const body = await request.json()
const task = await db.task.create({ data: body })
return NextResponse.json(task, { status: 201 })
}
Each HTTP method (GET, POST, PUT, DELETE) is a named export. Next.js routes the request to the matching function.
HTTP Methods Map to Actions
| Method | Purpose | Example | |--------|---------|---------| | GET | Read data | Fetch all tasks | | POST | Create data | Create a new task | | PUT | Update data | Update an existing task | | DELETE | Remove data | Delete a task |
Status Codes You Should Know
- 200 — OK (successful GET or PUT)
- 201 — Created (successful POST)
- 400 — Bad Request (invalid input from the client)
- 401 — Unauthorized (not logged in)
- 403 — Forbidden (logged in but insufficient permissions)
- 404 — Not Found (resource doesn't exist)
- 500 — Internal Server Error (something broke on the server)
Prompt pattern: "Create a tasks API with GET (list all), POST (create), PUT (update by ID), and DELETE (remove by ID) endpoints. Return appropriate status codes for each."
Dynamic Route Segments
For endpoints that operate on a specific resource, use dynamic segments:
// app/api/tasks/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const task = await db.task.findUnique({ where: { id: params.id } })
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 })
}
return NextResponse.json(task)
}
Server Actions
Server Actions are the modern way to handle mutations in Next.js. Instead of building API endpoints and calling them from the client, you write server functions that the client can call directly.
The Basics
// app/actions/tasks.ts
"use server"
import { revalidatePath } from "next/cache"
export async function createTask(formData: FormData) {
const title = formData.get("title") as string
const description = formData.get("description") as string
await db.task.create({
data: { title, description },
})
revalidatePath("/tasks")
}
The "use server" directive marks these functions as server-only. You can call them from Client Components or use them directly in form actions:
<form action={createTask}>
<input name="title" placeholder="Task title" />
<textarea name="description" placeholder="Description" />
<button type="submit">Create Task</button>
</form>
This form works even without JavaScript enabled in the browser — that's progressive enhancement.
When to Use Server Actions vs. API Routes
Server Actions when: the action is triggered by your own app (form submissions, button clicks), you want progressive enhancement, or you want simpler code.
API Routes when: you need an endpoint for external consumers (mobile apps, third-party integrations), you need webhooks, or you need fine-grained control over HTTP details.
Prompt: "Use Server Actions for the task creation and update forms. Use API routes only for the public API that the mobile app will consume."
REST API Design Principles
REST is a set of conventions for structuring API endpoints. Following them makes your API predictable and easy to consume.
Resource-Based URLs
URLs should represent resources (nouns), not actions (verbs):
Good:
GET /api/tasks → list tasks
POST /api/tasks → create a task
GET /api/tasks/123 → get task 123
PUT /api/tasks/123 → update task 123
DELETE /api/tasks/123 → delete task 123
Bad:
GET /api/getTasks
POST /api/createTask
POST /api/deleteTask/123
Consistent Response Format
Pick a response format and stick with it:
{
"data": { "id": "123", "title": "Buy groceries", "completed": false },
"error": null
}
For errors:
{
"data": null,
"error": { "code": "VALIDATION_ERROR", "message": "Title is required" }
}
Consistency matters because your frontend code can have a single function to handle all API responses.
Request Validation with Zod
Never trust data from the client. Even if your frontend validates inputs, someone could send a request directly to your API with garbage data. Server-side validation is non-negotiable.
Why Zod
Zod is a TypeScript-first validation library that works beautifully with AI. Its schemas are declarative and readable:
import { z } from "zod"
const CreateTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.coerce.date().optional(),
})
type CreateTaskInput = z.infer<typeof CreateTaskSchema>
Using Zod in API Routes
export async function POST(request: Request) {
const body = await request.json()
const result = CreateTaskSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
)
}
const task = await db.task.create({ data: result.data })
return NextResponse.json({ data: task }, { status: 201 })
}
The safeParse method returns either { success: true, data } or { success: false, error } — it never throws. This makes error handling clean and predictable.
Common Schema Patterns
// Email validation
const email = z.string().email("Invalid email address")
// Password with requirements
const password = z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number")
// Pagination parameters
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
Prompt: "Add Zod validation to all API routes. Validate request bodies for POST/PUT and query parameters for GET endpoints. Return structured validation errors."
Middleware Patterns
Middleware runs before your route handlers, letting you add cross-cutting concerns like authentication, logging, and rate limiting.
Next.js Middleware
// middleware.ts (in the root of your project)
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
// Check authentication for protected routes
const token = request.cookies.get("session-token")
if (request.nextUrl.pathname.startsWith("/dashboard") && !token) {
return NextResponse.redirect(new URL("/login", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
}
The matcher config tells Next.js which routes this middleware applies to. This is more efficient than running middleware on every request.
Prompt: "Add middleware that redirects unauthenticated users to /login for all /dashboard routes. Also add an API middleware that checks for a valid session token on all /api/protected/ routes."
Error Handling Done Right
Poor error handling is the most common issue in AI-generated code. AI tends to use optimistic patterns that ignore failure cases. You need to be explicit about error handling in your prompts.
Try/Catch in API Routes
export async function POST(request: Request) {
try {
const body = await request.json()
const validated = CreateTaskSchema.parse(body)
const task = await db.task.create({ data: validated })
return NextResponse.json({ data: task }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", details: error.flatten() } },
{ status: 400 }
)
}
console.error("Failed to create task:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Something went wrong" } },
{ status: 500 }
)
}
}
The Golden Rule: Never Expose Internal Errors
The catch block logs the real error for debugging but returns a generic message to the client. Never send stack traces, database errors, or internal details to users — they're security risks.
Custom Error Classes
For larger apps, custom error classes make handling cleaner:
class AppError extends Error {
constructor(
public code: string,
public statusCode: number,
message: string
) {
super(message)
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super("NOT_FOUND", 404, `${resource} not found`)
}
}
class ValidationError extends AppError {
constructor(message: string) {
super("VALIDATION_ERROR", 400, message)
}
}
Error Boundaries in React
Error boundaries catch JavaScript errors in the component tree and display a fallback UI instead of crashing the whole page:
// app/error.tsx
"use client"
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-xl font-semibold mb-4">Something went wrong</h2>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Try Again
</button>
</div>
)
}
Prompt: "Add comprehensive error handling to all API routes. Use try/catch, return consistent error responses, never expose internal errors, and add an error.tsx boundary for the app."
Rate Limiting
Without rate limiting, someone can flood your API with requests, degrading performance for everyone or running up your server costs.
Simple In-Memory Rate Limiter
const rateLimit = new Map<string, { count: number; resetTime: number }>()
function checkRateLimit(ip: string, limit = 100, windowMs = 60000): boolean {
const now = Date.now()
const record = rateLimit.get(ip)
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
return true
}
if (record.count >= limit) return false
record.count++
return true
}
For production, use a library like rate-limiter-flexible with Redis as the store. The in-memory approach works for development but doesn't scale across multiple server instances.
CORS Configuration
CORS (Cross-Origin Resource Sharing) controls which domains can call your API. If your frontend is at app.example.com and your API is at api.example.com, you need CORS.
In Next.js, you configure CORS headers in your route handlers or middleware:
const corsHeaders = {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders })
}
If your frontend and API are on the same domain (which they are in most Next.js apps), you don't need to worry about CORS at all.
Environment Variables and Secrets
Environment variables store configuration that changes between environments (development, staging, production) and secrets that should never be in your code.
The .env.local File
# .env.local (NEVER commit this file)
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
AUTH_SECRET="super-secret-value-change-in-production"
STRIPE_SECRET_KEY="sk_test_..."
# Client-side variables (visible in browser)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Critical rules:
- Add
.env.localto.gitignore(Next.js does this by default) - Only variables prefixed with
NEXT_PUBLIC_are exposed to the browser - Never put secret keys in
NEXT_PUBLIC_variables - Use different values in development vs. production
Access them in your code:
// Server-side only
const dbUrl = process.env.DATABASE_URL
// Available in client and server
const appUrl = process.env.NEXT_PUBLIC_APP_URL
Prompt: "Set up environment variables for the database URL, auth secret, and Stripe keys. Make sure only the app URL is exposed to the client."
Putting It Together
Let's see how all these pieces fit in a real example — a complete notes API:
// app/api/notes/route.ts
import { NextResponse } from "next/server"
import { z } from "zod"
import { db } from "@/lib/db"
import { getSession } from "@/lib/auth"
const CreateNoteSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
isPublic: z.boolean().default(false),
})
export async function GET(request: Request) {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Login required" } },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") ?? "1")
const limit = parseInt(searchParams.get("limit") ?? "20")
const notes = await db.note.findMany({
where: { userId: session.user.id },
orderBy: { updatedAt: "desc" },
skip: (page - 1) * limit,
take: limit,
})
const total = await db.note.count({ where: { userId: session.user.id } })
return NextResponse.json({
data: notes,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
})
} catch (error) {
console.error("Failed to fetch notes:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Something went wrong" } },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Login required" } },
{ status: 401 }
)
}
const body = await request.json()
const result = CreateNoteSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: { code: "VALIDATION_ERROR", details: result.error.flatten() } },
{ status: 400 }
)
}
const note = await db.note.create({
data: { ...result.data, userId: session.user.id },
})
return NextResponse.json({ data: note }, { status: 201 })
} catch (error) {
console.error("Failed to create note:", error)
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Something went wrong" } },
{ status: 500 }
)
}
}
This single file demonstrates: authentication checks, Zod validation, Prisma queries, pagination, consistent error handling, and proper status codes. Every pattern we covered in this lesson, working together.
What's Next
You can now build API routes, validate data, handle errors gracefully, and keep secrets safe. Your apps have a proper server foundation.
But where does the data actually live? In the next lesson, we'll tackle databases — from choosing between SQLite and PostgreSQL, to designing schemas with Prisma, to writing the queries that power your API. You'll learn how to describe your data model to AI and get a complete, production-ready database layer.