React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

skillsnextjsdata-fetchingcachingstreamingrevalidationserver-actions

Next.js Data Fetching Skill - A Claude Code skill recipe for mastering Next.js data fetching, streaming, and caching

These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.

Recipe

The complete SKILL.md content you can copy into .claude/skills/nextjs-data-fetching/SKILL.md:

---
name: nextjs-data-fetching
description: "Mastering data fetching with Next.js 15+, streaming, partial prerendering, and caching strategies. Use when asked to: data fetching, caching strategy, streaming, revalidation, server actions, fetch patterns, waterfall prevention, PPR."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
 
# Next.js Data Fetching
 
You are a Next.js data fetching expert. Provide authoritative guidance on fetching, caching, revalidation, streaming, and Server Actions.
 
## Fetching Strategy Decision Matrix
 
| Scenario | Strategy | Where |
|----------|----------|-------|
| Static page content | fetch at build time | Server Component |
| User-specific data | fetch at request time | Server Component with cookies/headers |
| Form submission | Server Action | "use server" function |
| Real-time updates | Client-side fetch (SWR or React Query) | Client Component |
| List + detail prefetch | Parallel fetches with Promise.all | Server Component |
| Infinite scroll | Client-side fetch with pagination | Client Component |
| Search with URL params | searchParams in page | Server Component |
| Optimistic mutation | useOptimistic + Server Action | Client Component |
 
## Server Component Fetching
 
### Basic Pattern
```tsx
// app/posts/page.tsx - Server Component (default)
async function getPosts() \{
  const res = await fetch("https://api.example.com/posts", \{
    next: \{ revalidate: 3600 \}, // ISR: revalidate every hour
  \});
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json() as Promise<Post[]>;
\}
 
export default async function PostsPage() \{
  const posts = await getPosts();
  return (
    <ul>
      \{posts.map((post) => (
        <li key=\{post.id\}>\{post.title\}</li>
      ))\}
    </ul>
  );
\}

Parallel Fetching (Prevent Waterfalls)

// BAD: Sequential waterfall
async function Page() \{
  const user = await getUser();    // 200ms
  const posts = await getPosts();  // 300ms
  // Total: 500ms
 
// GOOD: Parallel fetching
async function Page() \{
  const [user, posts] = await Promise.all([
    getUser(),   // 200ms
    getPosts(),  // 300ms
  ]);
  // Total: 300ms (max of both)

Fetching with Search Params

export default async function SearchPage(\{
  searchParams,
\}: \{
  searchParams: Promise<\{ q?: string; page?: string \}>;
\}) \{
  const \{ q, page \} = await searchParams;
  const results = await search(q ?? "", Number(page ?? "1"));
  return <SearchResults results=\{results\} />;
\}

Caching Layers

Next.js 15 changed the default: fetch requests are NO LONGER cached by default.

Request Memoization

  • Same fetch URL + options in the same render tree are deduplicated automatically
  • Only lasts for the duration of a single server request
  • No configuration needed

Data Cache (opt-in in Next.js 15+)

// Opt-in to caching
fetch(url, \{ cache: "force-cache" \});
 
// Cache with time-based revalidation
fetch(url, \{ next: \{ revalidate: 3600 \} \});
 
// No cache (default in Next.js 15)
fetch(url, \{ cache: "no-store" \});
// or simply: fetch(url) - no-store is the default

Full Route Cache

  • Static routes are fully cached at build time
  • Dynamic routes (using cookies, headers, searchParams) are rendered per request
  • Use generateStaticParams for static generation of dynamic routes

Revalidation Strategies

// Time-based revalidation
fetch(url, \{ next: \{ revalidate: 60 \} \});
 
// On-demand revalidation by path
import \{ revalidatePath \} from "next/cache";
revalidatePath("/posts");
 
// On-demand revalidation by tag
import \{ revalidateTag \} from "next/cache";
// When fetching:
fetch(url, \{ next: \{ tags: ["posts"] \} \});
// When mutating:
revalidateTag("posts");

Non-fetch Cache (unstable_cache / cacheTag)

import \{ unstable_cache \} from "next/cache";
 
const getCachedUser = unstable_cache(
  async (id: string) => db.user.findUnique(\{ where: \{ id \} \}),
  ["user"],       // cache key parts
  \{ revalidate: 3600, tags: ["user"] \}
);

Streaming with Suspense

import \{ Suspense \} from "react";
 
export default function DashboardPage() \{
  return (
    <div>
      <h1>Dashboard</h1>
      \{/* This renders immediately */\}
      <StaticHeader />
 
      \{/* These stream in independently */\}
      <Suspense fallback=\{<ChartSkeleton />\}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback=\{<TableSkeleton />\}>
        <RecentOrders />
      </Suspense>
    </div>
  );
\}
 
// Each async component streams when ready
async function RevenueChart() \{
  const data = await getRevenue(); // slow query
  return <Chart data=\{data\} />;
\}
 
async function RecentOrders() \{
  const orders = await getOrders(); // another slow query
  return <OrdersTable orders=\{orders\} />;
\}

Partial Prerendering (PPR)

PPR combines static and dynamic content in a single route:

// next.config.ts
const config = \{
  experimental: \{
    ppr: true,
  \},
\};
 
// app/product/[id]/page.tsx
import \{ Suspense \} from "react";
 
export default async function ProductPage(\{
  params,
\}: \{
  params: Promise<\{ id: string \}>;
\}) \{
  const \{ id \} = await params;
  const product = await getProduct(id); // static (cached)
 
  return (
    <div>
      \{/* Static shell - prerendered */\}
      <h1>\{product.name\}</h1>
      <p>\{product.description\}</p>
 
      \{/* Dynamic holes - streamed at request time */\}
      <Suspense fallback=\{<PriceSkeleton />\}>
        <DynamicPrice productId=\{id\} />
      </Suspense>
      <Suspense fallback=\{<ReviewsSkeleton />\}>
        <DynamicReviews productId=\{id\} />
      </Suspense>
    </div>
  );
\}

Server Actions (Mutations)

// actions.ts
"use server";
 
import \{ revalidatePath \} from "next/cache";
import \{ redirect \} from "next/navigation";
 
export async function createPost(formData: FormData) \{
  const title = formData.get("title") as string;
  const body = formData.get("body") as string;
 
  // Always validate server-side
  if (!title || title.length < 3) \{
    return \{ error: "Title must be at least 3 characters" \};
  \}
 
  await db.post.create(\{ data: \{ title, body \} \});
  revalidatePath("/posts");
  redirect("/posts");
\}

Waterfall Prevention Rules

  1. Never await sequentially when fetches are independent - Use Promise.all
  2. Push data fetching to the leaf components - Let each component fetch its own data
  3. Wrap slow components in Suspense - Stream them independently
  4. Preload patterns - Call fetch functions early, await later
  5. Use parallel routes - Each @slot loads independently with its own loading.tsx

Preload Pattern

// lib/data.ts
export function preloadUser(id: string) \{
  void getUser(id); // fire fetch, discard promise (request memoization caches it)
\}
 
// app/user/[id]/page.tsx
import \{ preloadUser \} from "@/lib/data";
 
export default async function UserPage(\{
  params,
\}: \{
  params: Promise<\{ id: string \}>;
\}) \{
  const \{ id \} = await params;
  preloadUser(id); // start fetching immediately
  // ... other work ...
  const user = await getUser(id); // hits memoization cache
  return <UserProfile user=\{user\} />;
\}

## Working Example

### Example 1: User asks "How should I fetch data for my dashboard?"

**User prompt:** "My dashboard has user info, recent activity, and analytics charts. It's slow because everything loads sequentially."

**Skill-guided response would include:**

```tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserInfoSkeleton, ActivitySkeleton, ChartSkeleton } from "./skeletons";

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-12 gap-6">
      <div className="col-span-4">
        <Suspense fallback={<UserInfoSkeleton />}>
          <UserInfo />
        </Suspense>
      </div>
      <div className="col-span-8">
        <Suspense fallback={<ChartSkeleton />}>
          <AnalyticsChart />
        </Suspense>
      </div>
      <div className="col-span-12">
        <Suspense fallback={<ActivitySkeleton />}>
          <RecentActivity />
        </Suspense>
      </div>
    </div>
  );
}

// Each component fetches independently - no waterfalls
async function UserInfo() {
  const user = await getCurrentUser();
  return <UserCard user={user} />;
}

async function AnalyticsChart() {
  const data = await getAnalytics();
  return <Chart data={data} />;
}

async function RecentActivity() {
  const activity = await getActivity();
  return <ActivityFeed items={activity} />;
}

Example 2: User asks "How do I handle caching for my product catalog?"

Skill-guided response would recommend:

  • Use ISR with revalidate: 3600 for product listings
  • Use on-demand revalidation via tags when products are updated
  • Use generateStaticParams for the most popular products
  • Stream dynamic content (price, stock) with Suspense

Deep Dive

How the Skill Works

This skill equips Claude with:

  1. Decision matrix - Maps every data fetching scenario to the correct strategy
  2. Caching layer knowledge - All four Next.js cache layers and how they interact
  3. Waterfall prevention - Patterns and rules to eliminate sequential fetching
  4. Streaming patterns - How to use Suspense boundaries for progressive rendering

Customization

Extend this skill by adding:

  • Your ORM-specific patterns (Prisma, Drizzle, etc.)
  • Custom caching wrappers used in your project
  • Specific API client configurations
  • Data fetching conventions (e.g., "always use our fetchApi wrapper")

How to Install

mkdir -p .claude/skills/nextjs-data-fetching
# Paste the Recipe content into .claude/skills/nextjs-data-fetching/SKILL.md

Gotchas

  • Next.js 15 changed fetch defaults - fetch is no longer cached by default. This is a breaking change from Next.js 14 where force-cache was the default.
  • Request memoization only works within a single render - It does not persist across different user requests.
  • revalidatePath revalidates all dynamic segments - revalidatePath("/posts/[id]") revalidates ALL posts, not just one.
  • Server Actions must return serializable data - You cannot return class instances, functions, or Dates.
  • searchParams makes a route dynamic - Using searchParams opts the entire route out of static rendering.

Alternatives

ApproachWhen to Use
SWRClient-side fetching with automatic revalidation
TanStack QueryComplex client-side cache management
tRPCEnd-to-end type-safe API layer
GraphQL (Apollo or Relay)Complex data requirements with relationships

FAQs

What changed about fetch caching defaults in Next.js 15?
  • In Next.js 14, fetch used force-cache by default (requests were cached)
  • In Next.js 15, fetch uses no-store by default (requests are NOT cached)
  • You must explicitly opt in with cache: "force-cache" or next: { revalidate: N }
How do you prevent sequential data fetching waterfalls in Server Components?
// Use Promise.all for independent fetches
const [user, posts] = await Promise.all([
  getUser(),
  getPosts(),
]);
  • Never await sequentially when fetches are independent
  • Alternatively, push fetching to leaf components and wrap each in <Suspense>
What is request memoization and how long does it last?
  • Same fetch URL + options in the same render tree are automatically deduplicated
  • It only lasts for the duration of a single server request
  • It does not persist across different user requests
  • No configuration is needed
How do you type the searchParams prop in a Next.js 15 page component?
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; page?: string }>;
}) {
  const { q, page } = await searchParams;
  // ...
}
  • searchParams is a Promise in Next.js 15+ and must be awaited
What is the difference between time-based and on-demand revalidation?
  • Time-based: fetch(url, { next: { revalidate: 60 } }) regenerates after 60 seconds
  • On-demand by path: revalidatePath("/posts") invalidates immediately when called
  • On-demand by tag: tag fetches with next: { tags: ["posts"] }, then call revalidateTag("posts")
Gotcha: Does revalidatePath("/posts/[id]") revalidate just one post?
  • No. revalidatePath("/posts/[id]") revalidates ALL pages matching that dynamic segment
  • It does not target a single post by ID
  • Use tag-based revalidation if you need to invalidate a specific resource
How does Partial Prerendering (PPR) combine static and dynamic content?
  • The static parts of the page are prerendered at build time and served from CDN
  • Dynamic parts are wrapped in <Suspense> boundaries with skeleton fallbacks
  • Dynamic content streams in at request time without blocking the static shell
  • Requires experimental: { ppr: true } in next.config.ts
What is the preload pattern and why is it useful?
export function preloadUser(id: string) {
  void getUser(id); // fire fetch, discard promise
}
// Later in the component:
preloadUser(id);
const user = await getUser(id); // hits memoization cache
  • Starts the fetch early so data is ready when you actually await it
  • Works because request memoization deduplicates the same fetch in one render
Gotcha: Can Server Actions return Date objects or class instances?
  • No. Server Actions must return serializable data only
  • You cannot return class instances, functions, or Date objects
  • Convert Dates to ISO strings and class instances to plain objects before returning
How do you cache non-fetch data (e.g., database queries) in Next.js?
import { unstable_cache } from "next/cache";
 
const getCachedUser = unstable_cache(
  async (id: string) => db.user.findUnique({ where: { id } }),
  ["user"],
  { revalidate: 3600, tags: ["user"] }
);
  • Use unstable_cache for ORM queries and other non-fetch data sources
  • Provide cache key parts and optional revalidation/tag configuration
What does using searchParams do to a route's rendering strategy?
  • Using searchParams makes the route dynamic
  • The entire route opts out of static rendering and is rendered per request
  • This applies even if the rest of the page could be static
How should you type a Server Action that receives FormData?
"use server";
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  // Always validate server-side with Zod
  const parsed = Schema.safeParse({ title });
  if (!parsed.success) return { error: "Invalid" };
}
  • The parameter is typed as FormData, not a typed object
  • Always parse and validate with Zod on the server side