Skip to main content

12.5 Data Fetching — Extended fetch, cache/revalidate, Direct DB Queries in Server Components

Next.js App Router extends the fetch API with built-in caching, revalidation, and memoization. You can query databases directly in server components, call external APIs, or combine multiple strategies.


Next.js Extended fetch​

Core Concepts​

Next.js 15's fetch extends the Web Fetch API with three cache options.

OptionBehaviorAnalogy
cache: 'force-cache'Fetch once then cache permanently (SSG)Printed newspaper
cache: 'no-store'Fetch fresh on every request (SSR)Live breaking news
next: { revalidate: N }Revalidate every N seconds (ISR)Periodic news refresh
// app/products/page.tsx

// ✅ Static Generation (SSG) — fetch once at build time
async function getStaticProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
return res.json();
}

// ✅ Server-Side Rendering (SSR) — fetch fresh on every request
async function getDynamicProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
});
return res.json();
}

// ✅ Incremental Static Regeneration (ISR) — revalidate every 60 seconds
async function getRevalidatedProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
});
return res.json();
}

Changes in Next.js 15​

Starting with Next.js 15, the default cache behavior of fetch has changed.

// Next.js 14 and below: default is force-cache (SSG)
// Next.js 15+: default is no-store (changed to SSR!)

// Explicitly setting cache is recommended in Next.js 15
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // explicitly specify when you want SSG
});

Basic Examples — Data Fetching in Server Components​

Simple fetch Example​

// app/posts/page.tsx
interface Post {
id: number;
title: string;
body: string;
userId: number;
}

async function getPosts(): Promise<Post[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 3600 }, // revalidate every 1 hour
});

if (!res.ok) {
throw new Error('Failed to fetch posts');
}

return res.json();
}

export default async function PostsPage() {
const posts = await getPosts();

return (
<main>
<h1>Posts</h1>
<ul>
{posts.slice(0, 10).map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
</main>
);
}

Dynamic Routes + Data Fetching​

// app/posts/[id]/page.tsx
interface Post {
id: number;
title: string;
body: string;
}

async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
next: { revalidate: 60 },
});

if (!res.ok) {
throw new Error('Post not found');
}

return res.json();
}

// Generate static paths (SSG)
export async function generateStaticParams() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts: Post[] = await res.json();

return posts.slice(0, 10).map((post) => ({
id: String(post.id),
}));
}

export default async function PostDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; // params is a Promise in Next.js 15
const post = await getPost(id);

return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}

Direct DB Queries in Server Components​

Server components run in a Node.js environment, so they can query databases directly. This eliminates an API round-trip, rendering DB results directly.

Prisma + PostgreSQL Example​

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const db =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
});

if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
// app/dashboard/page.tsx
import { db } from '@/lib/db';

// Direct DB query in a server component
export default async function DashboardPage() {
// Query the DB directly — no API fetch needed
const users = await db.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 10,
});

const totalCount = await db.user.count();

return (
<div>
<h1>Dashboard</h1>
<p>Total users: {totalCount}</p>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>
);
}

Parallel DB Queries for Better Performance​

// app/dashboard/stats/page.tsx
import { db } from '@/lib/db';

export default async function StatsPage() {
// ❌ Sequential queries — slow
// const users = await db.user.count();
// const posts = await db.post.count();
// const comments = await db.comment.count();

// ✅ Parallel queries — fast
const [userCount, postCount, commentCount, recentPosts] = await Promise.all([
db.user.count(),
db.post.count(),
db.comment.count(),
db.post.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true } } },
}),
]);

return (
<div>
<h1>Stats</h1>
<dl>
<dt>Users</dt><dd>{userCount}</dd>
<dt>Posts</dt><dd>{postCount}</dd>
<dt>Comments</dt><dd>{commentCount}</dd>
</dl>

<h2>Recent Posts</h2>
<ul>
{recentPosts.map((post) => (
<li key={post.id}>
{post.title} — {post.author.name}
</li>
))}
</ul>
</div>
);
}

Real-World Example — Complete Data Fetching Patterns​

Loading UI and Suspense Integration​

// app/products/loading.tsx  ← automatically acts as Suspense fallback
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4" />
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded mb-2" />
))}
</div>
);
}
// app/products/page.tsx
import { Suspense } from 'react';
import ProductList from '@/components/ProductList';
import ProductSkeleton from '@/components/ProductSkeleton';

// Independent streaming per component using Suspense
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
</main>
);
}
// components/ProductList.tsx
interface Product {
id: number;
title: string;
price: number;
category: string;
image: string;
}

async function getProducts(): Promise<Product[]> {
const res = await fetch('https://fakestoreapi.com/products', {
next: { revalidate: 300 }, // revalidate every 5 minutes
});

if (!res.ok) throw new Error('Failed to load products.');
return res.json();
}

export default async function ProductList() {
const products = await getProducts();

return (
<ul className="grid grid-cols-3 gap-4">
{products.map((product) => (
<li key={product.id} className="border rounded p-4">
<img src={product.image} alt={product.title} className="h-48 object-contain" />
<h3 className="font-bold mt-2">{product.title}</h3>
<p className="text-blue-600">${product.price}</p>
</li>
))}
</ul>
);
}

Error Handling — error.tsx​

// app/products/error.tsx
'use client'; // Error boundaries must be client components

import { useEffect } from 'react';

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Send to error monitoring service
console.error('Products page error:', error);
}, [error]);

return (
<div className="flex flex-col items-center justify-center min-h-64">
<h2 className="text-xl font-bold mb-4">Failed to load products</h2>
<p className="text-gray-500 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try Again
</button>
</div>
);
}

Tag-Based Cache Invalidation​

// app/actions/revalidate.ts
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

// Invalidate all caches with the given tag
export async function revalidateProducts() {
revalidateTag('products'); // invalidate all caches tagged 'products'
}

// Invalidate a specific path
export async function revalidateProductPage(id: string) {
revalidatePath(`/products/${id}`);
}
// fetch with tags
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
revalidate: 3600,
tags: ['products'], // assign a tag
},
});
return res.json();
}

async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 3600,
tags: ['products', `product-${id}`], // multiple tags
},
});
return res.json();
}

Request Memoization​

When fetch is called multiple times with the same URL and options within the same render tree, only one network request is made.

// fetch called in both layout and page → automatically deduplicated
// app/layout.tsx
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
cache: 'no-store',
});
return res.json();
}

export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getUser('me'); // 1 request
return (
<div>
<nav>Hello, {user.name}</nav>
{children}
</div>
);
}

// app/profile/page.tsx
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
cache: 'no-store',
});
return res.json();
}

export default async function ProfilePage() {
const user = await getUser('me'); // no extra request — memoized!
return <p>{user.email}</p>;
}

Memoizing DB Queries with cache()​

For direct DB queries that don't use fetch, use React's cache() function for memoization.

// lib/queries.ts
import { cache } from 'react';
import { db } from '@/lib/db';

// Wrapping with cache() prevents duplicate queries within the same render tree
export const getUser = cache(async (id: string) => {
return db.user.findUnique({
where: { id },
include: { profile: true },
});
});

export const getPostsByUser = cache(async (userId: string) => {
return db.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: 'desc' },
});
});
// app/users/[id]/page.tsx
import { getUser, getPostsByUser } from '@/lib/queries';

export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

// Parallel queries — getUser is memoized with cache()
const [user, posts] = await Promise.all([
getUser(id),
getPostsByUser(id),
]);

if (!user) return <p>User not found.</p>;

return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<h2>Posts ({posts.length})</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}

Streaming SSR and Sequential Data Fetching​

Streaming Independent Components​

// app/dashboard/page.tsx
import { Suspense } from 'react';

// Each component fetches data independently and streams to the client
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div>Loading stats...</div>}>
<StatsCard />
</Suspense>

<Suspense fallback={<div>Loading recent orders...</div>}>
<RecentOrders />
</Suspense>

<Suspense fallback={<div>Loading top products...</div>}>
<TopProducts />
</Suspense>

<Suspense fallback={<div>Loading user activity...</div>}>
<UserActivity />
</Suspense>
</div>
);
}

// Each component fetches its own data independently
async function StatsCard() {
// Simulate a slow API
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
}).then((r) => r.json());

return (
<div className="border rounded p-4">
<h3>Stats</h3>
<p>Visitors: {stats.visitors}</p>
<p>Revenue: {stats.revenue}</p>
</div>
);
}

When Sequential Fetching Is Needed​

// app/checkout/page.tsx
// Example where user → cart → recommendations must be fetched in order

async function CheckoutPage() {
// Step 1: Verify authentication
const user = await getUser();
if (!user) redirect('/login');

// Step 2: Fetch cart (requires userId)
const cart = await getCart(user.id);

// Step 3 can be separated with Suspense — parallel processing possible
return (
<div>
<h1>Checkout</h1>
<CartSummary cart={cart} />
<Suspense fallback={<div>Loading recommendations...</div>}>
<RecommendedItems userId={user.id} />
</Suspense>
</div>
);
}

// Pass userId as a prop to the server component
async function RecommendedItems({ userId }: { userId: string }) {
const items = await getRecommendations(userId);
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}

Pro Tips​

1. Function-Level Caching with unstable_cache​

Custom data sources that don't use fetch (DB, SDK, etc.) can also be cached.

import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';

// Cache the function result — revalidate after 1 hour, tagged 'products'
export const getCachedProducts = unstable_cache(
async () => {
return db.product.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
});
},
['products-list'], // cache key
{
revalidate: 3600, // 1 hour
tags: ['products'], // invalidation tag
}
);

// Usage
export default async function ProductsPage() {
const products = await getCachedProducts(); // served from cache
return <ProductGrid products={products} />;
}

2. Conditional Cache Strategy​

// Branch cache strategy by environment or user role
async function getPageData(isAdmin: boolean) {
const cacheOption = isAdmin
? { cache: 'no-store' as const } // admin: always fresh
: { next: { revalidate: 300 } }; // public: 5-minute cache

const res = await fetch('https://api.example.com/data', cacheOption);
return res.json();
}

3. Validating API Responses with Zod​

import { z } from 'zod';

const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});

const PostsSchema = z.array(PostSchema);

async function getPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 3600 },
});

if (!res.ok) throw new Error(`HTTP ${res.status}`);

const raw = await res.json();
const posts = PostsSchema.parse(raw); // runtime type validation
return posts;
}

4. Data Layer Separation Pattern​

// lib/services/product.service.ts
import { cache } from 'react';
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';

// Short cache: near-real-time data
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({ where: { id } });
});

// Long cache: infrequently changing data
export const getCategories = unstable_cache(
async () => db.category.findMany({ orderBy: { name: 'asc' } }),
['categories'],
{ revalidate: 86400, tags: ['categories'] } // 24 hours
);

// No cache: always needs the latest
export async function getUserOrders(userId: string) {
return db.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
}

5. Understanding the Cache Layers​

Request → Edge Cache (CDN) → Next.js Full Route Cache
→ React Server Component Cache (memoization)
→ Data Cache (fetch/unstable_cache)
→ Origin Server / DB
  • Full Route Cache: Caches the entire HTML + RSC Payload (static routes)
  • Data Cache: Caches individual fetch responses (persistent, requires explicit revalidation)
  • Request Memoization: Deduplicates within a single render tree (discarded after request)

6. Disabling Cache in Development​

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// Disable caching in development to always see fresh data
experimental: {
staleTimes: {
dynamic: 0, // dynamic routes: no cache
static: 300, // static routes: 5 minutes
},
},
};

export default nextConfig;