Data fetching in the App Router is refreshingly direct: make your Server Component async and await your data. The subtle part is caching - deciding whether data is fetched once at build time, on every request, or somewhere in between. This lesson gives you the patterns and the mental model.
No useEffect, no loading state juggling, no client-side waterfalls. Just await:
// app/products/page.js
export default async function ProductsPage() {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
const products = await res.json();
return (
<ul>
{products.map((p) => <li key={p.id}>{p.name} - ${p.price}</li>)}
</ul>
);
}You can also skip HTTP entirely and query a database or ORM directly inside the component - it runs only on the server.
Next.js extends fetch with caching options that map to three strategies:
// 1. Static - fetch once, reuse the result (like getStaticProps)
fetch(url, { cache: 'force-cache' });
// 2. Dynamic - fetch on every request (like getServerSideProps)
fetch(url, { cache: 'no-store' });
// 3. Incremental Static Regeneration - revalidate on a timer
fetch(url, { next: { revalidate: 60 } }); // fresh at most every 60sWith revalidate, the first visitor after the window expires still gets the cached page instantly while Next.js regenerates it in the background. This gives static-site speed with data that stays reasonably fresh - ideal for blogs, product listings, and marketing pages.
A version note: in Next.js 14, fetch results were cached by default; since Next.js 15 they are uncached by default. Do not rely on the default - state your intent explicitly with cache or revalidate options.
You can also configure caching per route segment with exported constants:
// app/news/page.js
export const revalidate = 300; // revalidate this page every 5 minutes
export const dynamic = 'force-dynamic'; // or: force this page to render per requestSegment options apply to the whole page, which is handy when data comes from a database rather than fetch.
Worried about calling the same fetch in a layout and a page? Do not be. Next.js deduplicates identical fetch requests within a single render pass, so the network call happens once. For non-fetch data sources, wrap the function in React's cache():
import { cache } from 'react';
import { db } from '@/lib/db';
export const getUser = cache(async (id) => {
return db.user.findUnique({ where: { id } });
});Slow data should not block the whole page. Drop a loading.js next to a page and Next.js instantly shows it while the page's data loads:
// app/products/loading.js
export default function Loading() {
return <p>Loading products...</p>;
}For finer control, wrap slow components in Suspense so the rest of the page streams in immediately:
import { Suspense } from 'react';
<Suspense fallback={<p>Loading reviews...</p>}>
<Reviews productId={id} />
</Suspense>When content changes because of an action - say, publishing a post - refresh the cache immediately with revalidatePath or revalidateTag inside a Server Action or route handler:
import { revalidatePath } from 'next/cache';
revalidatePath('/blog');Next up: Lesson 8 applies these skills to dynamic routes - pages whose URLs contain parameters like /blog/my-post.