Next.js — Data Fetching & Caching¶
OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x
Overview¶
Next.js 15 changed caching defaults: fetch requests are UNCACHED by default.
You must explicitly opt into caching. This page covers Server Actions, revalidation
strategies, the caching layers, and GE patterns for forms and mutations.
Caching Layers (Next.js 15)¶
| Layer | Location | Default | Purpose |
|---|---|---|---|
| Request Memoization | Server (per-request) | ON | Deduplicates identical fetch calls within one render |
| Data Cache | Server (persistent) | OFF (15+) | Persists fetch results across requests |
| Full Route Cache | Server (persistent) | Static routes only | Caches pre-rendered HTML + RSC payload |
| Router Cache | Client (in-memory) | ON (30s dynamic, 5min static) | Client-side navigation cache |
CRITICAL: Next.js 15 changed the default — fetch is NO LONGER cached automatically.
IF: migrating from Next.js 14
THEN: review ALL fetch calls — previously cached calls now hit the origin every time
Data Fetching in Server Components¶
Direct Database Access (GE Primary Pattern)¶
// src/app/(dashboard)/projects/page.tsx
import { db } from "@/lib/db";
import { projects } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export default async function ProjectsPage() {
const allProjects = await db
.select()
.from(projects)
.where(eq(projects.status, "active"))
.orderBy(projects.createdAt);
return <ProjectList projects={allProjects} />;
}
GE_CONVENTION: use Drizzle ORM directly in Server Components — no API layer needed
GE_CONVENTION: mark database modules with import "server-only"
External API Fetch¶
export default async function WeatherWidget() {
const res = await fetch("https://api.example.com/weather", {
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!res.ok) throw new Error("Failed to fetch weather");
const data = await res.json();
return <div>{data.temperature}°C</div>;
}
Revalidation Strategies¶
Static Generation (SSG) — Build Time¶
IF: content is truly static (legal pages, documentation)
THEN: use force-static — fastest possible response
Incremental Static Regeneration (ISR) — Time-Based¶
// Or per-fetch:
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
IF: content changes periodically (blog posts, product listings)
THEN: use ISR with appropriate revalidation interval
NOTE: uses stale-while-revalidate — serves stale content while fetching fresh
Server-Side Rendering (SSR) — Every Request¶
IF: content is user-specific or real-time (dashboard, notifications)
THEN: use force-dynamic — renders fresh on every request
On-Demand Revalidation — Event-Based¶
import { revalidatePath, revalidateTag } from "next/cache";
// By path — revalidates the specific route
revalidatePath("/projects");
// By tag — revalidates all fetches tagged with this value
revalidateTag("projects");
// Tag a fetch for targeted revalidation
const data = await fetch("https://api.example.com/projects", {
next: { tags: ["projects"] },
});
IF: content changes on user action (form submit, webhook)
THEN: use on-demand revalidation in Server Action or Route Handler
GE_CONVENTION: prefer revalidateTag over revalidatePath — more precise
Server Actions (GE Primary Mutation Pattern)¶
Server Actions are async functions that execute on the server. They replace API routes
for form submissions and data mutations.
Defining Server Actions¶
// src/lib/actions/projects.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { projects } from "@/lib/db/schema";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
const CreateProjectSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
});
export async function createProject(formData: FormData) {
const parsed = CreateProjectSchema.safeParse({
name: formData.get("name"),
description: formData.get("description"),
});
if (!parsed.success) {
return { error: parsed.issues[0].message }; // Zod v4: .issues not .errors
}
await db.insert(projects).values({
name: parsed.data.name,
description: parsed.data.description,
});
revalidateTag("projects");
redirect("/projects");
}
CHECK: Server Action file has "use server" as first line
CHECK: ALL input is validated with Zod before database operations
CHECK: Zod v4 uses .issues not .errors on ZodError
CHECK: revalidateTag() or revalidatePath() called after mutation
CHECK: Server Actions never return sensitive data (secrets, full user objects)
Using Server Actions in Forms¶
// Server Component form — no JavaScript needed for submission
import { createProject } from "@/lib/actions/projects";
export default function CreateProjectPage() {
return (
<form action={createProject}>
<input name="name" required />
<textarea name="description" />
<button type="submit">Create</button>
</form>
);
}
Progressive Enhancement with useActionState¶
"use client";
import { useActionState } from "react";
import { createProject } from "@/lib/actions/projects";
export function CreateProjectForm() {
const [state, formAction, isPending] = useActionState(createProject, null);
return (
<form action={formAction}>
<input name="name" required disabled={isPending} />
<textarea name="description" disabled={isPending} />
{state?.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
</form>
);
}
CHECK: useActionState (not useFormState — deprecated in React 19)
CHECK: form disables inputs during submission (isPending)
CHECK: error states are displayed to the user
Server Actions for Non-Form Mutations¶
"use client";
import { deleteProject } from "@/lib/actions/projects";
import { useTransition } from "react";
export function DeleteButton({ projectId }: { projectId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(() => deleteProject(projectId))}
>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}
Caching Patterns for GE¶
Pattern: Cached Database Query¶
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
const getCachedProjects = unstable_cache(
async () => {
return db.query.projects.findMany({
where: (p, { eq }) => eq(p.status, "active"),
});
},
["projects-list"], // Cache key
{ revalidate: 300, tags: ["projects"] } // 5 min TTL + tag
);
IF: database query is expensive and data does not change per-request
THEN: wrap with unstable_cache + tag for on-demand revalidation
ANTI_PATTERN: caching user-specific queries
FIX: only cache shared/public data — user-specific data should be dynamic
Pattern: Parallel Data Fetching¶
export default async function DashboardPage() {
// WRONG — sequential (waterfall)
// const projects = await getProjects();
// const stats = await getStats();
// RIGHT — parallel
const [projects, stats] = await Promise.all([
getProjects(),
getStats(),
]);
return <Dashboard projects={projects} stats={stats} />;
}
CHECK: independent data fetches use Promise.all()
ANTI_PATTERN: sequential awaits for independent data
FIX: use Promise.all() or separate Suspense boundaries
Pattern: Preloading Data¶
// src/lib/db/queries.ts
import { cache } from "react";
export const getProject = cache(async (id: string) => {
return db.query.projects.findOne({ where: (p, { eq }) => eq(p.id, id) });
});
// Preload in layout, use in page — only one query thanks to React cache()
export const preloadProject = (id: string) => void getProject(id);
// layout.tsx
import { preloadProject } from "@/lib/db/queries";
export default function ProjectLayout({ params }: Props) {
preloadProject(params.projectId); // Fire early
return <div>{children}</div>;
}
Opting Out of Caching¶
// Route-level: no caching for this page
export const dynamic = "force-dynamic";
// Fetch-level: no caching for this request
const data = await fetch(url, { cache: "no-store" });
// Dynamic functions automatically opt out of caching:
import { cookies, headers } from "next/headers";
const cookieStore = await cookies(); // Makes entire route dynamic
IF: route uses cookies(), headers(), or searchParams
THEN: route is automatically dynamic — no need for force-dynamic
Environment Variables for Data Fetching¶
// Server-only (no NEXT_PUBLIC_ prefix) — safe for API keys
const API_KEY = process.env.STRIPE_SECRET_KEY;
// Client-accessible (NEXT_PUBLIC_ prefix) — only public values
const API_URL = process.env.NEXT_PUBLIC_API_URL;
CHECK: API keys and secrets never have NEXT_PUBLIC_ prefix
CHECK: process.env values are available at build time (not runtime) unless using dynamic
IF: env var must change without rebuild
THEN: use runtime env vars with dynamic = "force-dynamic" or server-side only access
Anti-Patterns¶
ANTI_PATTERN: fetching data in Client Components when Server Component is possible
FIX: fetch in Server Component, pass result as props
ANTI_PATTERN: creating /api/ routes just to fetch data for your own pages
FIX: fetch directly in Server Components — no network hop needed
ANTI_PATTERN: using useEffect + fetch for initial data load
FIX: use Server Components or Server Actions
ANTI_PATTERN: not handling Server Action errors
FIX: always return { error: string } on failure, display to user
ANTI_PATTERN: calling redirect() inside try/catch in Server Actions
FIX: redirect() throws internally — call it AFTER try/catch block
// WRONG
try {
await db.insert(projects).values(data);
redirect("/projects"); // redirect throws, caught by catch
} catch (e) {
return { error: "Something went wrong" }; // redirect swallowed
}
// RIGHT
let shouldRedirect = false;
try {
await db.insert(projects).values(data);
shouldRedirect = true;
} catch (e) {
return { error: "Failed to create project" };
}
if (shouldRedirect) redirect("/projects");
ANTI_PATTERN: mutating data in GET route handlers
FIX: use POST/PUT/DELETE, or Server Actions
Cross-References¶
READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/server-components.md
READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md
READ_ALSO: wiki/docs/stack/nextjs/checklist.md