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 defaultFull 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
- Never await sequentially when fetches are independent - Use Promise.all
- Push data fetching to the leaf components - Let each component fetch its own data
- Wrap slow components in Suspense - Stream them independently
- Preload patterns - Call fetch functions early, await later
- 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: 3600for product listings - Use on-demand revalidation via tags when products are updated
- Use
generateStaticParamsfor the most popular products - Stream dynamic content (price, stock) with Suspense
Deep Dive
How the Skill Works
This skill equips Claude with:
- Decision matrix - Maps every data fetching scenario to the correct strategy
- Caching layer knowledge - All four Next.js cache layers and how they interact
- Waterfall prevention - Patterns and rules to eliminate sequential fetching
- 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
fetchApiwrapper")
How to Install
mkdir -p .claude/skills/nextjs-data-fetching
# Paste the Recipe content into .claude/skills/nextjs-data-fetching/SKILL.mdGotchas
- Next.js 15 changed fetch defaults - fetch is no longer cached by default. This is a breaking change from Next.js 14 where
force-cachewas 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
| Approach | When to Use |
|---|---|
| SWR | Client-side fetching with automatic revalidation |
| TanStack Query | Complex client-side cache management |
| tRPC | End-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,
fetchusedforce-cacheby default (requests were cached) - In Next.js 15,
fetchusesno-storeby default (requests are NOT cached) - You must explicitly opt in with
cache: "force-cache"ornext: { 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
awaitsequentially 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;
// ...
}searchParamsis aPromisein 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 callrevalidateTag("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_cachefor 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
searchParamsmakes 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