Next.js

Data Fetching in Next.js

40 min Lesson 7 of 40

Data Fetching in Next.js

Next.js provides powerful data fetching capabilities that work seamlessly with Server Components. This lesson covers the extended fetch API, caching strategies, revalidation techniques, and patterns for efficient data loading.

The Extended fetch() API

Next.js extends the native fetch() Web API to add automatic request deduplication, caching, and revalidation. All fetch requests are automatically cached on the server.

Key Innovation: In Next.js 13+, you can fetch data directly in Server Components without creating separate API routes. This simplifies your architecture and improves performance.

Basic Data Fetching

// app/posts/page.tsx async function getPosts() { const res = await fetch('https://api.example.com/posts'); if (!res.ok) { throw new Error('Failed to fetch posts'); } return res.json(); } export default async function PostsPage() { const posts = await getPosts(); return ( <div> <h1>Blog Posts</h1> <ul> {posts.map(post => ( <li key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </li> ))} </ul> </div> ); }
Best Practice: Create separate async functions for your data fetching logic. This keeps your components clean and makes the functions reusable across your application.

Caching Strategies

Next.js automatically caches fetch requests on the server. You can control caching behavior using the cache option:

1. Force Cache (Default)

Caches the response until manually invalidated:

// This is the default behavior fetch('https://api.example.com/posts', { cache: 'force-cache' }); // Equivalent to: fetch('https://api.example.com/posts');

2. No Store (Dynamic)

Fetch fresh data on every request:

// app/dashboard/page.tsx async function getRealtimeData() { const res = await fetch('https://api.example.com/realtime', { cache: 'no-store' }); return res.json(); } export default async function DashboardPage() { const data = await getRealtimeData(); return ( <div> <h1>Live Dashboard</h1> <p>Last updated: {data.timestamp}</p> <p>Active users: {data.activeUsers}</p> </div> ); }
Performance Impact: Using cache: 'no-store' means the page will be dynamically rendered for every request, which can be slower than static rendering. Only use this for truly dynamic data.

Revalidation Strategies

Revalidation allows you to purge cached data and re-fetch it at specified intervals or on-demand.

Time-Based Revalidation

Automatically revalidate data after a specific time period:

// Revalidate every 60 seconds async function getPosts() { const res = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } }); return res.json(); } export default async function PostsPage() { const posts = await getPosts(); return ( <div> <h1>Posts (Updated every 60 seconds)</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
How It Works: The first request caches the data. Subsequent requests within 60 seconds serve the cached version. After 60 seconds, the next request triggers a background revalidation while serving the stale cache. Once revalidation completes, the cache updates.

Route Segment Config

You can set default revalidation behavior for an entire route segment:

// app/blog/page.tsx export const revalidate = 3600; // Revalidate every hour async function getPosts() { const res = await fetch('https://api.example.com/posts'); return res.json(); } export default async function BlogPage() { const posts = await getPosts(); return ( <div> <h1>Blog</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </div> ); }

On-Demand Revalidation

Manually revalidate specific paths or cache tags using revalidatePath() or revalidateTag():

// app/api/revalidate/route.ts import { revalidatePath, revalidateTag } from 'next/cache'; import { NextRequest } from 'next/server'; export async function POST(request: NextRequest) { const secret = request.nextUrl.searchParams.get('secret'); // Validate secret token if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ message: 'Invalid secret' }, { status: 401 }); } // Revalidate specific path revalidatePath('/posts'); // Or revalidate by cache tag revalidateTag('posts'); return Response.json({ revalidated: true, now: Date.now() }); }

Using cache tags in your fetch requests:

// Tag your fetch requests async function getPosts() { const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } }); return res.json(); } async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } }); return res.json(); } // Revalidate all requests tagged with 'posts' // By calling: POST /api/revalidate?secret=xxx
Use Case: On-demand revalidation is perfect for content management systems. When an editor publishes a new post, trigger revalidation via webhook to immediately update your site without waiting for time-based revalidation.

Parallel Data Fetching

Fetch multiple data sources in parallel to minimize loading time:

// app/dashboard/page.tsx async function getUser() { const res = await fetch('https://api.example.com/user'); return res.json(); } async function getStats() { const res = await fetch('https://api.example.com/stats'); return res.json(); } async function getNotifications() { const res = await fetch('https://api.example.com/notifications'); return res.json(); } export default async function DashboardPage() { // ❌ Sequential - slow (3 seconds total if each takes 1 second) // const user = await getUser(); // const stats = await getStats(); // const notifications = await getNotifications(); // ✅ Parallel - fast (1 second total) const [user, stats, notifications] = await Promise.all([ getUser(), getStats(), getNotifications() ]); return ( <div> <h1>Welcome, {user.name}!</h1> <div>Views: {stats.views}</div> <div>Notifications: {notifications.length}</div> </div> ); }
Performance Impact: Using Promise.all() can reduce your page load time dramatically. If you have 3 requests that each take 1 second, sequential fetching takes 3 seconds, while parallel fetching takes only 1 second.

Sequential Data Fetching

Sometimes you need to fetch data sequentially because one request depends on another:

// app/user/[id]/page.tsx async function getUser(id: string) { const res = await fetch(`https://api.example.com/users/${id}`); return res.json(); } async function getUserPosts(userId: string) { const res = await fetch(`https://api.example.com/users/${userId}/posts`); return res.json(); } export default async function UserPage({ params }: { params: { id: string } }) { // First fetch the user const user = await getUser(params.id); // Then fetch their posts using their ID const posts = await getUserPosts(user.id); return ( <div> <h1>{user.name}'s Profile</h1> <p>Bio: {user.bio}</p> <h2>Posts</h2> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }

Preloading Data

Use the preload pattern to start fetching data before it's needed:

// lib/data.ts import { cache } from 'react'; export const getUser = cache(async (id: string) => { const res = await fetch(`https://api.example.com/users/${id}`); return res.json(); }); // Create a preload function export const preloadUser = (id: string) => { void getUser(id); // Start fetching but don't await }; // app/user/[id]/layout.tsx import { preloadUser, getUser } from '@/lib/data'; export default async function UserLayout({ children, params }: { children: React.ReactNode; params: { id: string }; }) { // Start loading user data preloadUser(params.id); return ( <div> <UserNav id={params.id} /> {children} </div> ); } // app/user/[id]/UserNav.tsx import { getUser } from '@/lib/data'; export default async function UserNav({ id }: { id: string }) { // This will use the preloaded data const user = await getUser(id); return ( <nav> <img src={user.avatar} alt={user.name} /> <span>{user.name}</span> </nav> ); }
React cache(): The cache() function from React deduplicates requests within a single render pass. Multiple calls to getUser(id) with the same ID will only fetch once.

Error Handling

Properly handle errors in your data fetching functions:

// app/posts/page.tsx async function getPosts() { try { const res = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } }); if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } return res.json(); } catch (error) { console.error('Failed to fetch posts:', error); return []; // Return fallback data } } export default async function PostsPage() { const posts = await getPosts(); if (posts.length === 0) { return ( <div> <h1>Posts</h1> <p>No posts available at the moment.</p> </div> ); } return ( <div> <h1>Blog Posts</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }

Data Fetching Patterns

Pattern 1: Fetch in Layout and Page

// app/blog/layout.tsx async function getCategories() { const res = await fetch('https://api.example.com/categories'); return res.json(); } export default async function BlogLayout({ children }) { const categories = await getCategories(); return ( <div> <aside> <h3>Categories</h3> <ul> {categories.map(cat => ( <li key={cat.id}>{cat.name}</li> ))} </ul> </aside> <main>{children}</main> </div> ); } // app/blog/page.tsx async function getPosts() { const res = await fetch('https://api.example.com/posts'); return res.json(); } export default async function BlogPage() { const posts = await getPosts(); return ( <div> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> </article> ))} </div> ); }

Pattern 2: Fetch with Search Params

// app/search/page.tsx async function searchProducts(query: string, category?: string) { const params = new URLSearchParams({ q: query, ...(category && { category }) }); const res = await fetch(`https://api.example.com/search?${params}`, { cache: 'no-store' // Search results should be fresh }); return res.json(); } export default async function SearchPage({ searchParams }: { searchParams: { q: string; category?: string } }) { const { q, category } = searchParams; if (!q) { return <div>Enter a search query</div>; } const results = await searchProducts(q, category); return ( <div> <h1>Search results for "{q}"</h1> {results.length === 0 ? ( <p>No results found</p> ) : ( <ul> {results.map(product => ( <li key={product.id}>{product.name}</li> ))} </ul> )} </div> ); }

Database Queries

You can query databases directly in Server Components using ORMs like Prisma:

// app/posts/page.tsx import { prisma } from '@/lib/prisma'; export default async function PostsPage() { const posts = await prisma.post.findMany({ where: { published: true }, include: { author: { select: { name: true, avatar: true } } }, orderBy: { createdAt: 'desc' }, take: 20 }); return ( <div> <h1>Latest Posts</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>By {post.author.name}</p> <p>{post.excerpt}</p> </article> ))} </div> ); }
No API Routes Needed: With Server Components, you can skip creating API routes for data fetching. Query your database directly in components, reducing code complexity and improving performance.

Request Memoization

Next.js automatically deduplicates identical fetch requests during a single render pass:

// Multiple components can call the same function async function getUser() { const res = await fetch('https://api.example.com/user'); return res.json(); } // app/page.tsx async function Header() { const user = await getUser(); // First call return <header>{user.name}</header>; } async function Sidebar() { const user = await getUser(); // Deduplicated return <aside>{user.email}</aside>; } export default function Page() { return ( <> <Header /> <Sidebar /> </> ); } // Result: Only ONE fetch request is made!

Practice Exercise

Task: Build a blog with the following data fetching requirements:

  1. Create a posts listing page that revalidates every 5 minutes
  2. Create a post detail page that fetches post data and related posts in parallel
  3. Implement a search page that fetches fresh results on every search
  4. Add an API route to trigger on-demand revalidation of specific posts
  5. Create a dashboard that fetches user data, analytics, and recent activity in parallel

Requirements:

  • Use appropriate caching strategies for each page
  • Implement proper error handling
  • Use cache tags for granular revalidation
  • Optimize parallel data fetching where applicable
  • Add TypeScript types for all data structures

Bonus Challenge: Implement preloading for the post detail page to start fetching data in the layout before the page component renders.

Summary

  • Next.js extends fetch() with automatic caching and deduplication
  • Use cache: 'force-cache' (default) for static data, 'no-store' for dynamic data
  • Implement time-based revalidation with next: { revalidate: seconds }
  • Use on-demand revalidation with revalidatePath() and revalidateTag()
  • Fetch data in parallel with Promise.all() to improve performance
  • Use sequential fetching when one request depends on another
  • Implement the preload pattern for early data fetching
  • Query databases directly in Server Components without API routes
  • Next.js automatically deduplicates identical fetch requests in a single render
  • Always implement proper error handling in data fetching functions