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

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

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

Dynamic (No Cache — Always Fresh)

tsx

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

Time-Based Revalidation

tsx

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
ts

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

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

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
tsx
tsx

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

Fetch in parallel with Promise.all:

tsx

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

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

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

For permanent redirects:

tsx

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

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

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

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.


Knowledge Check

When using <Suspense> to handle data fetching in Server Components, what is the primary benefit?


Why must you be careful when using redirect() inside a try/catch block?


What is the correct way to handle expected failure states, such as querying a database for a product ID that does not exist?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.