Back to Blog
Frontend

Next.js App Router: Mastering Data Fetching Patterns

February 8, 202613 min read

The App Router introduces async components, parallel data fetching, request deduplication, and intelligent caching. Learn the patterns that make Next.js 14+ applications fast and maintainable.

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

Tags

Next.jsApp RouterReactServer ComponentsPerformanceCaching