Skip to content

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

// Page is rendered at build time, served as static HTML
export const dynamic = "force-static";

IF: content is truly static (legal pages, documentation)
THEN: use force-static — fastest possible response

Incremental Static Regeneration (ISR) — Time-Based

// Revalidate every 60 seconds
export const revalidate = 60;
// 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

export const dynamic = "force-dynamic";

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