Next.js App Router: Mastering Data Fetching Patterns
Introduction
The Next.js App Router does not just change where your components live — it fundamentally changes how data flows through your application. Async Server Components, the fetch cache, Suspense streaming, and React cache() give you tools that eliminate entire categories of boilerplate.
This guide covers every major data fetching pattern in the App Router with production-ready examples.
1. Async Server Components (The Default)
// app/blog/page.tsx
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();
}
export default async function BlogPage() {
const posts = await getPosts(); // Awaited directly in the component
return (
<ul>
{posts.map((post: Post) => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
The fetch call is automatically cached and deduplicated for the duration of the server request.
2. Parallel Data Fetching
Avoid sequential awaits for independent data:
// Bad — sequential: 300ms + 400ms = 700ms total
async function ProfilePage({ params }: { params: { id: string } }) {
const user = await getUser(params.id); // 300ms
const posts = await getUserPosts(params.id); // 400ms
// ...
}
// Good — parallel: max(300ms, 400ms) = 400ms total
async function ProfilePage({ params }: { params: { id: string } }) {
const [user, posts] = await Promise.all([
getUser(params.id),
getUserPosts(params.id),
]);
// ...
}
3. Request Deduplication with React cache()
When the same data is needed in multiple components in the same render tree, use React's cache() to ensure it's fetched only once:
// lib/queries.ts
import { cache } from 'react';
import { db } from './db';
export const getUser = cache(async (id: string) => {
console.log('Fetching user:', id); // Only logs once even if called 5 times
return db.user.findUnique({ where: { id } });
});
// app/layout.tsx — calls getUser()
// app/profile/page.tsx — also calls getUser()
// Both use the same cached result within one request
4. Streaming with Suspense
Prioritise your most important content; stream the rest:
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<>
{/* Rendered immediately — no data needed */}
<DashboardHeader />
{/* Streams when ready — each resolves independently */}
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<StatCardSkeleton />}>
<RevenueCard /> {/* async, 50ms */}
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<UserCountCard /> {/* async, 80ms */}
</Suspense>
<Suspense fallback={<StatCardSkeleton />}>
<ActiveJobsCard /> {/* async, 200ms */}
</Suspense>
</div>
{/* Big table streams separately */}
<Suspense fallback={<TableSkeleton />}>
<RecentOrdersTable />
</Suspense>
</>
);
}
The page is interactive immediately; each card fills in as its data arrives.
5. Caching Strategies
// Static — cached indefinitely until manually revalidated
fetch(url, { cache: 'force-cache' });
// or simply: fetch(url) — force-cache is the default
// ISR — revalidate every N seconds
fetch(url, { next: { revalidate: 3600 } });
// Dynamic — no caching, fresh every request
fetch(url, { cache: 'no-store' });
// or: fetch(url, { next: { revalidate: 0 } })
// Tag-based revalidation
fetch(url, { next: { tags: ['posts'] } });
// Later: revalidateTag('posts') in a Server Action
6. Server Actions for Mutations
// app/posts/new/page.tsx
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) throw new Error('Missing fields');
await db.post.create({ data: { title, content } });
revalidateTag('posts'); // Bust the posts cache
redirect('/blog');
}
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Publish</button>
</form>
);
}
No API route. No fetch. No state management. The form submits to a server function.
7. Loading UI and Error Boundaries
app/
blog/
page.tsx — the page
loading.tsx — shown immediately while page.tsx awaits
error.tsx — shown if page.tsx throws
not-found.tsx — shown if notFound() is called
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-20 bg-gray-200 rounded-lg" />
))}
</div>
);
}
Conclusion
The App Router's data fetching model is opinionated but powerful. Embrace async components, use React cache() for deduplication, wrap slow components in Suspense, and reach for Server Actions before building an API route.
Key takeaways:
- Always fetch in parallel with Promise.all when data is independent
- React cache() is essential for shared data in large component trees
- loading.tsx and Suspense serve different purposes — use both
- Tag-based revalidation is cleaner than time-based ISR for most content