Data Fetching
Patterns for fetching, caching, and mutating data across server components, client components, and API routes.
Data fetching in Next.js App Router happens at multiple layers — server components, API routes, and client-side hooks — each with different caching characteristics and appropriate use cases. Using the wrong layer produces either over-fetching, under-caching, or unnecessary client-side JavaScript.
Fetching Layers
| Layer | When it runs | Caching | Best for |
|---|---|---|---|
| Server Component (async) | Request time, on the server | Next.js fetch cache (configurable) | Initial page data, SEO-critical content, database queries |
| Route Handler (API route) | On HTTP request | Controlled by response headers | Client-triggered mutations, webhook endpoints, third-party callbacks |
| Server Action | On form submission or client call | Revalidates on demand | Form submissions, mutations that should revalidate server data |
| SWR / TanStack Query | On client mount or trigger | In-memory client cache | Real-time data, user-interactive filtering, polling |
Server Component Fetching
Fetch data directly in Server Components for initial page loads. This eliminates the loading spinner on first render, improves Core Web Vitals, and keeps data-fetching logic on the server where it has access to secrets and direct DB connections.
// app/dashboard/page.tsx
import { Suspense } from "react"
import { getUser, getProjects, getNotifications } from "@/lib/data"
export default async function DashboardPage() {
// Fetch in parallel — do NOT await sequentially
const [user, projects, notifications] = await Promise.all([
getUser(),
getProjects(),
getNotifications(),
])
return (
<main>
<DashboardHeader user={user} />
<Suspense fallback={<ProjectListSkeleton />}>
<ProjectGrid projects={projects} />
</Suspense>
<NotificationPanel notifications={notifications} />
</main>
)
}
// lib/data.ts — server-only data access
import { db } from "@/lib/db"
import { auth } from "@/lib/auth"
export async function getProjects() {
const session = await auth()
if (!session) throw new Error("Unauthorized")
return db.project.findMany({
where: { teamId: session.user.teamId },
orderBy: { updatedAt: "desc" },
take: 20,
})
}API Routes
Write API routes in app/api/[route]/route.ts. Every route handler must validate input, check authentication, and handle errors explicitly. Never trust client input.
import { NextRequest, NextResponse } from "next/server"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
const updateSchema = z.object({
status: z.enum(["active", "paused", "completed", "archived"]),
name: z.string().min(1).max(100).optional(),
})
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const body = await request.json()
const parsed = updateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
)
}
// Verify ownership before mutation
const project = await db.project.findFirst({
where: { id, teamId: session.user.teamId },
})
if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
const updated = await db.project.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(updated)
}Client-Side Fetching
Use SWR for client-side data that needs to stay fresh, be revalidated on focus, or respond to user interactions. Never fetch inside useEffect — this pattern produces race conditions, missing loading states, and no caching.
Do not use useEffect for data fetching. It does not handle race conditions, has no built-in caching, and requires manual cleanup. SWR handles all of this by default.
Loading & Error States
Every data-dependent UI must have three states beyond the happy path: loading, empty, and error. These are not edge cases — they are the states users see most often on slow connections or after API failures.
export function ProjectList({ status }: { status: string }) {
const { projects, isLoading, error } = useProjects(status)
// Loading state — use skeletons, not spinners for list content
if (isLoading) return <ProjectListSkeleton count={6} />
// Error state — actionable, not apologetic
if (error) return (
<div role="alert" className="text-sm text-destructive py-8 text-center">
Failed to load projects.{" "}
<button onClick={() => mutate()} className="underline">Retry</button>
</div>
)
// Empty state — guide the user, do not leave a blank space
if (projects.length === 0) return (
<div className="py-16 text-center">
<p className="text-sm text-muted-foreground mb-4">
No {status !== "all" ? status : ""} projects found.
</p>
<Button asChild size="sm">
<Link href="/app/projects/new">Create a project</Link>
</Button>
</div>
)
return <ul>{projects.map(p => <ProjectCard key={p.id} project={p} />)}</ul>
}AI agents can scaffold the complete data fetching layer for a resource: route handler with input validation, SWR hooks with all three states (loading, empty, error), and server action definitions. Prompt with the resource schema and the states you need. Agents reliably produce the loading/error/empty pattern if you explicitly list the states — but they default to the happy path if you do not specify them.
Mutations & Optimistic Updates
For mutations that should feel instant, apply an optimistic update — show the expected result immediately, then reconcile with the actual server response.
export function TaskItem({ task }: { task: Task }) {
const { trigger } = useSWRMutation(
`/api/tasks/${task.id}`,
(url, { arg }: { arg: { completed: boolean } }) =>
fetch(url, { method: "PATCH", body: JSON.stringify(arg) }).then(r => r.json())
)
const handleToggle = async () => {
// Optimistic update — mutate local cache immediately
await mutate(
`/api/tasks?projectId=${task.projectId}`,
(tasks: Task[]) =>
tasks?.map(t =>
t.id === task.id ? { ...t, completed: !t.completed } : t
),
false // don't revalidate yet
)
try {
await trigger({ completed: !task.completed })
} catch {
// Revert on failure — SWR will revalidate from server
await mutate(`/api/tasks?projectId=${task.projectId}`)
toast.error("Failed to update task. Changes reverted.")
}
}
return <Checkbox checked={task.completed} onCheckedChange={handleToggle} />
}