Back/Module F-4 Data Fetching in the App Router
Module F-4·23 min read

async/await directly in Server Components, fetch() with Next.js cache extensions, loading skeletons with Suspense, and the redirect/notFound control-flow functions.

F-4 — Data Fetching in the App Router

Who this is for: Developers who understand the Server/Client Component boundary from F-3 and now want to know how to actually get data into their components. This module covers the practical mechanics — fetching data in Server Components, how Next.js extends fetch(), where Suspense fits in, and the control-flow helpers that keep your code clean.


The Old Way vs The New Way

In the Pages Router, data fetching was a page-level concern. getServerSideProps, getStaticProps, getStaticPaths — all of these were special functions that ran at the page boundary and drilled data down as props. Your components were passive recipients. A <ProductReviews> component couldn't fetch its own reviews — it had to receive them from the page that fetched everything.

In the App Router, every Server Component can fetch its own data. There's no special function, no getServerSideProps, no props drilling required. A <ProductReviews> component that needs reviews just queries for them directly:

tsx
// components/ProductReviews.tsx (Server Component) async function getReviews(productId: string) { const reviews = await db.reviews.findMany({ where: { productId }, orderBy: { createdAt: 'desc' }, take: 10, }); return reviews; } export default async function ProductReviews({ productId }: { productId: string }) { const reviews = await getReviews(productId); if (reviews.length === 0) { return <p className="text-zinc-400">No reviews yet.</p>; } return ( <ul className="space-y-4"> {reviews.map(review => ( <li key={review.id}> <p className="font-semibold">{review.authorName}</p> <p>{review.body}</p> </li> ))} </ul> ); }

No useEffect. No loading state. No useState. The component is async, it awaits its data, and it renders. Next.js handles the rest.


Fetching with fetch() — Next.js Extensions

Next.js extends the native fetch() API with additional options that control caching and revalidation behaviour. These only apply to fetch() calls inside Server Components and Route Handlers — they don't affect client-side fetches.

Static (Cached Forever)

tsx
// Default behaviour in Next.js 14 — cached indefinitely const data = await fetch('https://api.example.com/products'); // Explicit — same as default in Next.js 14 const data = await fetch('https://api.example.com/products', { cache: 'force-cache', });

In Next.js 14, fetch was cached by default. This changed in Next.js 15.

Dynamic (No Cache — Always Fresh)

tsx
// Opt out of caching — always fetches fresh const data = await fetch('https://api.example.com/inventory', { cache: 'no-store', });

Use no-store for data that must be current at request time: inventory counts, user-specific content, real-time prices.

Time-Based Revalidation

tsx
// Re-fetch in the background after 60 seconds const data = await fetch('https://api.example.com/blog-posts', { next: { revalidate: 60 }, });

This is ISR at the fetch level. The first request uses the cached data. After 60 seconds, the next request triggers a background re-fetch. The user still gets a fast response from cache; the fresh data is stored for subsequent requests.

Tag-Based Revalidation

tsx
// Tag this fetch — can be invalidated on demand const data = await fetch('https://api.example.com/products', { next: { tags: ['products'] }, });
ts
// In a Server Action or Route Handler — invalidate the tag import { revalidateTag } from 'next/cache'; revalidateTag('products');

Tag-based revalidation lets you invalidate specific cached data on demand — when a product is updated, for instance, you call revalidateTag('products') and the next request for any fetch tagged with 'products' will re-fetch. This is how you build CMS integrations where publishing content immediately updates the live site.


Next.js 15 Changed the Default

This is a migration trap worth understanding explicitly. In Next.js 14, fetch() was cached by default. In Next.js 15, fetch() is uncached by default — it behaves like cache: 'no-store' unless you explicitly opt in.

tsx
// Next.js 14: cached by default — this is static const data = await fetch('https://api.example.com/data'); // Next.js 15: NOT cached by default — this is dynamic const data = await fetch('https://api.example.com/data'); // Next.js 15: explicitly cached const data = await fetch('https://api.example.com/data', { cache: 'force-cache', });

If you're upgrading a Next.js 14 project to 15, every fetch() call without an explicit cache option just became dynamic. Audit your data fetching on upgrade — your build output (the ○/λ symbols from F-1) will tell you if pages that should be static are now rendering dynamically.


Direct Database Access (No fetch() Required)

fetch() caching only applies to HTTP calls. When you're querying a database directly with Prisma, Drizzle, or raw SQL, you're not using fetch() at all — you're using a database client. In this case, Next.js's fetch() cache extensions don't apply.

tsx
// Server Component — direct database query import { db } from '@/lib/db'; export default async function UserProfile({ userId }: { userId: string }) { const user = await db.users.findUnique({ where: { id: userId } }); if (!user) notFound(); return <ProfileCard user={user} />; }

For caching database queries, Next.js provides unstable_cache (Next.js 14/15) and the newer use cache directive (Next.js 15+ experimental). These are covered in depth in P-6. For Foundation-level work, the important thing to know is: database queries in Server Components work exactly like you'd expect — you just call them. No ceremony required.


React's cache() — Request-Level Deduplication

React exports a cache() function that deduplicates calls within a single request. If two Server Components in the same render both call the same function with the same arguments, the function executes once and the result is shared.

ts
// lib/queries.ts import { cache } from 'react'; export const getUser = cache(async (userId: string) => { return db.users.findUnique({ where: { id: userId } }); });
tsx
// app/dashboard/page.tsx — calls getUser import { getUser } from '@/lib/queries'; export default async function DashboardPage({ params }) { const user = await getUser(params.userId); return <DashboardLayout user={user} />; }
tsx
// components/DashboardSidebar.tsx — also calls getUser, same request import { getUser } from '@/lib/queries'; export default async function DashboardSidebar({ userId }: { userId: string }) { const user = await getUser(userId); // ← returns memoised result, no second DB call return <UserAvatar user={user} />; }

cache() memoises the function for the duration of the React render tree. The database is only queried once per request, regardless of how many components call getUser. This is the correct pattern for query functions you'll call from multiple components.

cache() is per-request — it resets on every new request. It's not a persistent cache. For persistent caching across requests, you need unstable_cache or use cache (P-6).


Parallel Data Fetching

Fetching data sequentially is the most common performance mistake in Server Components:

tsx
// ❌ Sequential — product loads, then reviews load, total wait = product time + reviews time export default async function ProductPage({ params }) { const { id } = await params; const product = await getProduct(id); // waits const reviews = await getReviews(id); // only starts after product resolves return <Product product={product} reviews={reviews} />; }

Fetch in parallel with Promise.all:

tsx
// ✅ Parallel — both start simultaneously, total wait = max(product time, reviews time) export default async function ProductPage({ params }) { const { id } = await params; const [product, reviews] = await Promise.all([ getProduct(id), getReviews(id), ]); return <Product product={product} reviews={reviews} />; }

The difference: if getProduct takes 80ms and getReviews takes 120ms, sequential fetching takes 200ms. Parallel fetching takes 120ms. On a page with three or four data sources, this compounds quickly.

When to use Promise.all vs sequential:

  • Use Promise.all when fetches are independent of each other
  • Use sequential await when the second fetch needs data from the first

Streaming with Suspense

Promise.all is great when all your data sources are similar in speed. But what if product data is in a fast cache (20ms) and reviews come from a slow third-party API (800ms)?

With Promise.all, the entire page waits 800ms before any content renders. With Suspense, you can show the fast content immediately and stream the slow content when it's ready.

tsx
// app/products/[id]/page.tsx import { Suspense } from 'react'; import ProductDetails from '@/components/ProductDetails'; import ProductReviews from '@/components/ProductReviews'; import ReviewsSkeleton from '@/components/ReviewsSkeleton'; export default async function ProductPage({ params }) { const { id } = await params; return ( <div> {/* Renders immediately — product data is fast */} <ProductDetails productId={id} /> {/* Streams in when reviews resolve — shows skeleton while waiting */} <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={id} /> </Suspense> </div> ); }

ProductDetails fetches fast data and renders immediately. ProductReviews is wrapped in <Suspense> — while it's fetching, the browser shows ReviewsSkeleton. When the reviews data resolves, React streams the actual content in and swaps it for the skeleton.

The user sees a fully rendered product page instantly. Reviews appear as a secondary load a moment later. This is streaming SSR in action — no client-side JavaScript required, no loading state managed with useState, no blank screen.

The key rule: Each <Suspense> boundary is independent. Wrap slow data sources in their own boundaries. Fast content renders immediately; slow content streams when ready.


The loading.tsx Shortcut

In F-2 we covered loading.tsx as the automatic Suspense boundary for a route segment. It's worth connecting that file to this module's concepts: loading.tsx is equivalent to wrapping your page.tsx in <Suspense fallback={<Loading />}> automatically.

For route-level loading states (the entire page segment is loading), use loading.tsx. For component-level loading states (one slow section within an otherwise fast page), use <Suspense> directly with a skeleton component.


notFound() and redirect() — Control Flow as Functions

Two functions from next/navigation that act as control-flow mechanisms inside Server Components:

notFound()

Stops execution and renders the nearest not-found.tsx:

tsx
import { notFound } from 'next/navigation'; export default async function ProductPage({ params }) { const { id } = await params; const product = await db.products.findUnique({ where: { id } }); if (!product) notFound(); // ← throws internally, never returns return <ProductDetail product={product} />; }

After notFound(), no code below it executes. Next.js renders the not-found.tsx from the nearest ancestor that has one.

redirect()

Stops execution and sends a redirect response:

tsx
import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; export default async function DashboardPage() { const session = await getSession(); if (!session) redirect('/login'); // ← 307 temporary redirect by default return <Dashboard user={session.user} />; }

For permanent redirects:

tsx
import { redirect } from 'next/navigation'; export default async function OldProductPage({ params }) { const { slug } = await params; redirect(`/products/${slug}`, 'replace'); // or use permanentRedirect() for 308 }

Both notFound() and redirect() work by throwing special errors internally that Next.js catches and handles. This means they stop execution cleanly — you don't need return after them, but adding one doesn't hurt (TypeScript will sometimes require it for type narrowing).

Do not call redirect() inside a try/catch block unless you re-throw it. Catching it silently swallows the redirect:

tsx
// ❌ The redirect is caught and swallowed — nothing happens try { redirect('/login'); } catch (e) { console.error('something went wrong'); } // ✅ If you must use try/catch, re-throw non-redirect errors import { isRedirectError } from 'next/dist/client/components/redirect'; try { await riskyOperation(); redirect('/success'); } catch (e) { if (isRedirectError(e)) throw e; // let the redirect through handleError(e); }

Error Handling in Server Components

Server Components can throw errors. When they do, the nearest error.tsx catches them and renders the error UI. You don't need explicit try/catch for most cases — let errors propagate to the boundary.

For expected failure states (notFound, empty results), handle them explicitly with early returns or notFound(). For unexpected errors (database connection failure, third-party API timeout), let them propagate to error.tsx.

tsx
export default async function ProductPage({ params }) { const { id } = await params; // Expected failure: no product — render 404 const product = await db.products.findUnique({ where: { id } }); if (!product) notFound(); // Expected empty state: render empty UI const reviews = await getReviews(id); return ( <div> <ProductDetail product={product} /> {reviews.length === 0 ? <p>No reviews yet</p> : <ReviewList reviews={reviews} /> } </div> ); }

Unexpected errors from db.products.findUnique() (a thrown exception) will propagate up to the nearest error.tsx automatically. You don't need to wrap every database call in try/catch.


Anatomy of a Real Data Fetching Page

Putting it all together — a product page that uses parallel fetching, Suspense for a slow section, and control-flow helpers:

tsx
// app/products/[id]/page.tsx import { Suspense } from 'react'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import ProductDetail from '@/components/ProductDetail'; import ProductReviews from '@/components/ProductReviews'; import RelatedProducts from '@/components/RelatedProducts'; import ReviewsSkeleton from '@/components/ReviewsSkeleton'; import RelatedSkeleton from '@/components/RelatedSkeleton'; interface PageProps { params: Promise<{ id: string }>; } export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { id } = await params; const product = await getProduct(id); if (!product) return { title: 'Product Not Found' }; return { title: product.name, description: product.description, }; } export default async function ProductPage({ params }: PageProps) { const { id } = await params; const product = await getProduct(id); if (!product) notFound(); return ( <div className="max-w-4xl mx-auto px-4 py-8"> <ProductDetail product={product} /> <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={id} /> </Suspense> <Suspense fallback={<RelatedSkeleton />}> <RelatedProducts categoryId={product.categoryId} excludeId={id} /> </Suspense> </div> ); }

Three data sources: product (awaited before render), reviews (Suspense boundary), related products (separate Suspense boundary). Fast content renders immediately. Two slow sections stream in independently. The page is fully server-rendered with zero client-side data fetching code.


Where We Go From Here

F-5 covers dynamic routing in depth — how params work, catch-all routes, generateStaticParams for pre-rendering at build time, and the navigation hooks available in Client Components (useRouter, usePathname, useSearchParams, useLinkStatus).

The data fetching patterns in this module are what you'll use in F-8 to build the first complete application. By then, fetching in Server Components and wrapping slow sections in Suspense will feel like the natural way to write React — because it is.

Discussion