Skip to main content
Advertisement

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;
Advertisement