Design Engineering
Build

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

LayerWhen it runsCachingBest for
Server Component (async)Request time, on the serverNext.js fetch cache (configurable)Initial page data, SEO-critical content, database queries
Route Handler (API route)On HTTP requestControlled by response headersClient-triggered mutations, webhook endpoints, third-party callbacks
Server ActionOn form submission or client callRevalidates on demandForm submissions, mutations that should revalidate server data
SWR / TanStack QueryOn client mount or triggerIn-memory client cacheReal-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.

Parallel data fetching in a Server Component
// 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.

app/api/projects/[id]/route.ts
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.

Complete state handling pattern
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.

Optimistic update with SWR
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} />
}

On this page