Skip to main content

Data fetching in Next.js

Data fetching in Next.js App Router uses async/await directly in Server Components, no special page functions required.

Theory

TL;DR

  • Server Components can be async functions; just await your data inside
  • fetch is extended by Next.js: cached by default, configurable per request
  • cache: 'no-store' = fresh every request (SSR). next: { revalidate: N } = stale-while-revalidate (ISR)
  • Sequential await creates waterfalls; use Promise.all for independent data
  • Client Components need 'use client' + useEffect or SWR for interactive fetching

Quick example

tsx
// app/dashboard/page.tsx export default async function Dashboard() { const data = await fetch('https://api.itlead.org/users', { next: { revalidate: 3600 } // Cached, refreshed every hour }).then(res => res.json()); return ( <ul> {data.map(user => <li key={user.id}>{user.name}</li>)} </ul> ); } // No useEffect. No useState. No client bundle for this component.

The component is async, so await works at the top level. Next.js runs it on the server, caches the result, and sends HTML to the client.

How caching options map to rendering modes

Before App Router, you picked a rendering mode per page: getStaticProps for SSG, getServerSideProps for SSR, getStaticProps with revalidate for ISR. Now all three translate to fetch options on individual requests:

tsx
// SSG equivalent: cached indefinitely (or until revalidated) const res = await fetch('https://api.itlead.org/docs') // SSR equivalent: fresh on every request const res = await fetch('https://api.itlead.org/feed', { cache: 'no-store' }) // ISR equivalent: cached, refreshed in background after N seconds const res = await fetch('https://api.itlead.org/problems', { next: { revalidate: 300 } }) // On-demand revalidation: tag the request, then call revalidateTag() const res = await fetch('https://api.itlead.org/problems', { next: { tags: ['problems'] } })

This is a meaningful shift. You no longer choose caching at the page level. Each fetch call inside a component can have its own strategy, so a single page can mix static and dynamic data without any special configuration.

Parallel fetching

Sequential awaits create a waterfall. If getUser takes 200ms and getStats takes 300ms, sequential execution costs 500ms. Parallel costs 300ms.

tsx
// Bad: each awaits the previous export default async function DashboardPage() { const user = await getUser() const stats = await getStats() // waits for getUser const activity = await getActivity() // waits for getStats } // Good: all three fire at once export default async function DashboardPage() { const [user, stats, activity] = await Promise.all([ getUser(), getStats(), getActivity() ]) }

I've seen this waterfall pattern cause 3-4x slowdowns in dashboard pages where each data source was completely independent. It looks fine in code review and only shows up under real load.

Preload pattern with React.cache

Promise.all works when you fetch in one place. But when data is needed across a component tree, you want the request to start as early as possible and deduplicate if called more than once.

tsx
// lib/problems.ts import { cache } from 'react' export const getProblems = cache(async () => { const res = await fetch('https://api.itlead.org/problems') return res.json() }) export function preloadProblems() { void getProblems() // Starts the request, does not block }
tsx
// app/problems/page.tsx import { getProblems, preloadProblems } from '@/lib/problems' export default async function ProblemsPage() { preloadProblems() // Kicks off the fetch early // ...render other stuff, then: const problems = await getProblems() // Already in-flight or cached }

React.cache guarantees the underlying request runs once per render, even if getProblems is called in multiple components. It is the React-native alternative to SWR deduplication, but for server-side code.

Streaming with Suspense

For slow data sources, wrapping an async component in Suspense lets the rest of the page render immediately.

tsx
// app/reports/page.tsx import { Suspense } from 'react' async function SlowChart() { const data = await fetch('https://api.itlead.org/analytics', { next: { tags: ['charts'] } }).then(r => r.json()) return <Chart data={data} /> } async function QuickHeader() { const stats = await db.stats.findFirst() return <header>Users: {stats.count}</header> } export default function Reports() { return ( <> <QuickHeader /> <Suspense fallback={<div>Loading chart...</div>}> <SlowChart /> </Suspense> </> ) }

QuickHeader renders and streams immediately. SlowChart streams in when its fetch resolves. No blocking.

Client-side fetching

For interactive features like search or filtering, server fetching does not work. The data depends on user input that does not exist at render time.

tsx
'use client' import { useEffect, useState } from 'react' export function ProblemSearch() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) useEffect(() => { if (!query) return const controller = new AbortController() fetch(`/api/search?q=${query}`, { signal: controller.signal }) .then(res => res.json()) .then(setResults) return () => controller.abort() // Cancel on query change }, [query]) return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul> </div> ) }

Load initial data on the server, pass it as props to Client Components, and use client-side fetching only for user-triggered updates.

When to use which approach

  • Static content (docs, blog posts): fetch with default cache
  • User-specific data (profile, dashboard): fetch with cache: 'no-store'
  • Frequently updated content (leaderboard, prices): next: { revalidate: N }
  • CMS-managed content: next: { tags: ['content'] } + revalidateTag in a Server Action
  • Search, filtering, pagination: Client Component with useEffect or SWR
  • Slow data alongside fast UI: wrap in Suspense

Common mistakes

Sequential awaits for independent data. Already covered above, but it is the most common performance issue in App Router codebases. Always check if your awaits actually depend on each other.

Forgetting cache: 'no-store' for user-specific data.

tsx
// Wrong: one cached response served to all users const user = await fetch(`/api/user/${id}`) // Right: fresh per request const user = await fetch(`/api/user/${id}`, { cache: 'no-store' })

The default cache behavior means the same response could go to a different user. For any personalized data, always opt out.

Using await fetch in a Client Component at the top level.

tsx
// Wrong: Client Components are not async functions 'use client' const data = await fetch('/api') // SyntaxError

Client Components cannot be async. Use useEffect or a library like SWR.

Mutating fetched data before returning it.

tsx
// Wrong: mutates the cached object const data = await fetch(url).then(r => r.json()) data.push(extraItem) // Modifies shared cache reference

Next.js caches the response body. Mutating it affects every subsequent read from that cache. Clone first: const data = structuredClone(await res.json()).

Time-based revalidation when you need on-demand updates. If content changes unpredictably (a CMS publish, a price change), revalidate: 300 means up to 5 minutes of stale data. Use tags and revalidateTag in a Server Action or Route Handler instead.

Real-world usage

  • T3 Stack (Next.js + tRPC + Prisma): Server Components with await db.query() for admin panels
  • Vercel Commerce: fetch Stripe APIs with revalidate: 300 for product dashboards
  • Supabase examples: createServerClient in Server Components for auth and user data
  • Shadcn/UI docs: static fetch for MDX files at build time

Follow-up questions

Q: What is the difference between next.revalidate and cache: 'no-store'?
A: revalidate caches the response and refreshes it in the background after N seconds (stale-while-revalidate). no-store skips the cache entirely and fetches fresh data on every request.

Q: What happens if a fetch throws inside a Server Component?
A: The error bubbles up to the nearest error.tsx file. Add an error boundary at the appropriate route segment to handle it.

Q: How does React.cache differ from Next.js built-in fetch deduplication?
A: Next.js automatically deduplicates identical fetch calls (same URL plus options) within a single request. React.cache works for any async function, not just fetch, so it covers Prisma queries and other data sources.

Q: When should you use SWR or TanStack Query instead of useEffect?
A: When you need automatic revalidation on focus, polling, optimistic updates, or complex cache management. useEffect is fine for simple one-off fetches; dedicated libraries handle edge cases better.

Q: (Senior) How does Next.js handle fetch deduplication across Suspense boundaries?
A: Identical fetch calls within a single RSC render are deduplicated via an in-memory cache scoped to the request. Even if two Suspense-wrapped components call the same URL concurrently, Next.js sends one actual network request and shares the result between them.

Examples

Basic: Server Component with a direct database query

tsx
// app/problems/page.tsx import { db } from '@/lib/db' export default async function ProblemsPage() { const problems = await db.problem.findMany({ orderBy: { difficulty: 'asc' } }) return ( <ul> {problems.map(p => ( <li key={p.id}>{p.name} ({p.difficulty})</li> ))} </ul> ) } // Runs on the server. The Prisma client never reaches the client bundle.

Direct database access in the component. No API route needed. The connection string and ORM stay server-side.

Intermediate: Dashboard with parallel fetches and mixed caching

tsx
// app/admin/products/page.tsx import { db } from '@/lib/prisma' export default async function ProductsPage() { const [products, sales] = await Promise.all([ db.product.findMany({ include: { category: true } }), fetch('https://api.stripe.com/v1/analytics', { headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` }, next: { revalidate: 300 } // Stripe data refreshed every 5 min }).then(r => r.json()) ]) return ( <div> <h1>Products ({products.length})</h1> <pre>{JSON.stringify(sales.summary, null, 2)}</pre> </div> ) } // DB query and Stripe fetch run in parallel. // Stripe response is cached; DB query is always fresh.

Two data sources, two caching strategies, one parallel request. Prisma does not go through fetch, so Next.js does not cache it automatically. The Stripe call is.

Advanced: Streaming dashboard with Suspense

tsx
// app/reports/page.tsx import { Suspense } from 'react' import { db } from '@/lib/db' async function SalesChart() { const data = await fetch('https://api.itlead.org/analytics/heavy', { next: { tags: ['charts'] } }).then(r => r.json()) return <Chart data={data} /> } async function QuickStats() { const stats = await db.stats.findFirst() return <p>Total users: {stats.count}</p> } export default function ReportsPage() { return ( <main> <QuickStats /> <Suspense fallback={<p>Loading chart...</p>}> <SalesChart /> </Suspense> </main> ) } // QuickStats renders and streams immediately. // SalesChart streams in when the slow fetch resolves. // No full-page blocking.

ReportsPage itself is not async. It just composes two async components. Each one manages its own data independently, and Suspense turns the slow chart into a non-blocking slot.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?