Backend Integration
Connecting your frontend to backend APIs with copy-paste route handlers, request bodies, and curl commands you can run locally
Connect your frontend to backend services.
Every example below comes in a matched pair: the backend route handler
(app/api/.../route.ts) and the frontend call that hits it, plus the
request body shape and a curl command. Copy the route handler into a
Next.js app, run pnpm dev, then paste the curl into a second terminal to
test it — no UI required.
Fetch Pattern
A single typed helper keeps every request consistent — base URL, JSON headers, and error handling in one place.
// lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"
export async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json", ...options?.headers },
...options,
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json() as Promise<T>
}
// Usage
const users = await apiFetch<User[]>("/api/users")
Error Handling
Wrap responses so callers get typed, actionable errors instead of a bare
fetch rejection.
// lib/api-error.ts
export class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message)
}
}
export async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new ApiError(res.status, body.message || "Request failed")
}
return res.json() as Promise<T>
}
GET a List + POST to Create
The most common pair: fetch a collection, then add to it. The frontend POST
sends a JSON body; the backend reads it with await request.json().
Validating the Request Body
Never trust the body. Validate shape and types before touching your data, and
return 400 with a helpful message when it's wrong.
Add -i to see status codes
curl -i prints response headers including the status line, so you can
confirm you got 201 Created or 400 Bad Request — not just the body.
Dynamic Routes: PATCH & DELETE by ID
Update or delete a single record using the id from the URL path. In the App
Router, params is a Promise — remember to await it.
Query Params & Pagination
Filtering and paging live in the query string, read via request.nextUrl.searchParams.
File Upload with FormData
For files, send FormData instead of JSON — do not set Content-Type
yourself; the browser adds the multipart boundary automatically.
Sending Auth Headers & Cookies
Protected endpoints read a Bearer token from the Authorization header (or a
cookie). The client attaches it on every request.
Client Component: Form → POST → State
A complete React client component that submits a JSON body, shows loading and
error states, and renders the response. Copy it straight into a .tsx file.
"use client"
import { useState } from "react"
export function CreateUserForm() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle")
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus("saving")
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
})
if (!res.ok) throw new Error(await res.text())
setName("")
setEmail("")
setStatus("idle")
} catch {
setStatus("error")
}
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
required
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<button disabled={status === "saving"}>
{status === "saving" ? "Saving…" : "Create user"}
</button>
{status === "error" && <p role="alert">Something went wrong.</p>}
</form>
)
}
Local Testing Cheat Sheet
Keep this handy while building. Start the dev server with pnpm dev, then in a
second terminal:
# GET — pretty-print JSON with jq (optional)
curl -s http://localhost:4000/api/users | jq
# POST JSON body
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alan Turing","email":"alan@example.com"}'
# PATCH / DELETE by id
curl -X PATCH http://localhost:4000/api/users/1 -H "Content-Type: application/json" -d '{"name":"New Name"}'
curl -X DELETE http://localhost:4000/api/users/1
# Send an auth header
curl http://localhost:4000/api/me -H "Authorization: Bearer dev-token-123"
# Upload a file
curl -X POST http://localhost:4000/api/upload -F "file=@./photo.png" -F "title=Demo"
# Read a body from a file instead of inline
curl -X POST http://localhost:4000/api/users \
-H "Content-Type: application/json" \
--data @./body.json
# Show status code and headers
curl -i http://localhost:4000/api/users
Prefer an editor over the terminal? Save this as requests.http and use the
VS Code REST Client extension or JetBrains' built-in HTTP client — click
"Send Request" above each block:
### List users
GET http://localhost:4000/api/users
### Create a user
POST http://localhost:4000/api/users
Content-Type: application/json
{
"name": "Alan Turing",
"email": "alan@example.com"
}
### Authenticated request
GET http://localhost:4000/api/me
Authorization: Bearer dev-token-123
See It In Action
1// app/api/notes/route.ts2const notes: Array<{ id: number; text: string }> = []3let nextId = 14 5export async function POST(request: Request) {6const body = (await request.json()) as { text?: string }7 8if (typeof body.text !== "string" || body.text.trim() === "") {9return Response.json(10{ message: "text is required and must be a string" },11{ status: 400 },12)13}14 15const note = { id: nextId++, text: body.text }16notes.push(note)17return Response.json(note, { status: 201 })18}Parses the JSON body, validates that `text` is a non-empty string (returning 400 if not), then creates an in-memory note with an auto-incrementing id and responds with 201 Created.