//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
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>
);
\}// 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)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\} />;
\}Next.js 15 changed the default: fetch requests are NO LONGER cached by default.
// 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// 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");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"] \}
);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\} />;
\}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>
);
\}// 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");
\}// 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} />;
}
Skill-guided response would recommend:
revalidate: 3600 for product listingsgenerateStaticParams for the most popular productsThis skill equips Claude with:
Extend this skill by adding:
fetchApi wrapper")mkdir -p .claude/skills/nextjs-data-fetching
# Paste the Recipe content into .claude/skills/nextjs-data-fetching/SKILL.mdforce-cache was the default.revalidatePath("/posts/[id]") revalidates ALL posts, not just one.| 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 |
fetch used force-cache by default (requests were cached)fetch uses no-store by default (requests are NOT cached)cache: "force-cache" or next: { revalidate: N }// Use Promise.all for independent fetches
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
]);await sequentially when fetches are independent<Suspense>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 awaitedfetch(url, { next: { revalidate: 60 } }) regenerates after 60 secondsrevalidatePath("/posts") invalidates immediately when callednext: { tags: ["posts"] }, then call revalidateTag("posts")revalidatePath("/posts/[id]") revalidates ALL pages matching that dynamic segment<Suspense> boundaries with skeleton fallbacksexperimental: { ppr: true } in next.config.tsexport function preloadUser(id: string) {
void getUser(id); // fire fetch, discard promise
}
// Later in the component:
preloadUser(id);
const user = await getUser(id); // hits memoization cacheimport { unstable_cache } from "next/cache";
const getCachedUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
["user"],
{ revalidate: 3600, tags: ["user"] }
);unstable_cache for ORM queries and other non-fetch data sourcessearchParams makes the route dynamic"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" };
}FormData, not a typed objectReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥