WritingHow to Fetch Data in the Next.js App Router — LinkIT
5 min readnext.js, data-fetching, app-router, server-components

How to Fetch Data in the Next.js App Router

A practical guide to fetching data in Next.js App Router using server components, parallel fetching, Suspense streaming, and Server Actions for mutations.

App Router data fetching can combine async Server Components, parallel requests, and Suspense boundaries. Choose where data loads and caches based on dependencies, freshness requirements, and the parts of the page that can render independently.

The Foundation: Async Server Components

In the App Router, page and layout components are server components by default, and they support async/await directly. This is the primary data-fetching primitive.

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.products.findUnique({ where: { id: params.id } })
 
  if (!product) notFound()
 
  return <ProductDetail product={product} />
}

Server Components can fetch without a client-side effect. Keep secrets in server code and return only the data the rendered UI needs.

How to Fetch Data in Parallel

Sequential awaits delay independent requests. If two data sources are independent, fetching them one after the other adds their latencies together.

Sequential (slow):

const user = await getUser(userId)
const orders = await getOrders(userId)

Parallel (fast):

const [user, orders] = await Promise.all([
  getUser(userId),
  getOrders(userId),
])

Use Promise.all whenever your awaits do not depend on each other. The benefit depends on which requests are on the critical rendering path.

Streaming with Suspense

Not all data is equally fast. A user's account info loads in milliseconds; their order history might take longer. With Suspense, you can stream the fast parts immediately and slot in the slower parts as they resolve — without blocking the initial HTML.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { AccountSummary } from './account-summary'
import { OrderHistory } from './order-history'
import { OrderSkeleton } from './order-skeleton'
 
export default function DashboardPage() {
  return (
    <div>
      <AccountSummary />
      <Suspense fallback={<OrderSkeleton />}>
        <OrderHistory />
      </Suspense>
    </div>
  )
}

AccountSummary is a fast server component that renders immediately. OrderHistory is wrapped in Suspense — Next.js streams the skeleton first and replaces it with real content when the data resolves. The boundary allows the fallback to render while the order query is pending.

Nesting Suspense Boundaries

You can nest multiple Suspense boundaries to control the streaming order. Each boundary resolves independently. A complex dashboard can have four or five independent boundaries, each showing a skeleton until its data is ready.

Caching and Revalidation

Current Next.js does not persistently cache fetch responses by default. Request memoization during a render and caching across requests are separate behaviors; check the settings for your Next.js version.

You control freshness with the next option on fetch:

  • fetch(url) — not persistently cached by default in current Next.js
  • fetch(url, { next: { revalidate: 60 } }) — stale after 60 seconds (ISR behavior)
  • fetch(url, { cache: 'no-store' }) — always fresh (SSR behavior)

The example below uses unstable_cache. Current projects using Cache Components should review the use cache API and their version’s revalidation behavior before adopting it.

import { unstable_cache } from 'next/cache'
 
const getCachedProduct = unstable_cache(
  async (id: string) => db.products.findUnique({ where: { id } }),
  ['product'],
  { revalidate: 300, tags: ['products'] }
)

Tag-based revalidation lets you invalidate by category rather than by individual URL. When a product is updated, call revalidateTag('products') from a Server Action and every cached query tagged with 'products' refreshes on the next request.

Server Actions for Mutations

Reads happen in server components. Writes happen in Server Actions. A Server Action is an async function marked with 'use server' that runs on the server and can be called from a form's action attribute or from a client component.

// app/actions.ts
'use server'
 
import { revalidateTag } from 'next/cache'
 
export async function updateProduct(formData: FormData) {
  const id = formData.get('id') as string
  const name = formData.get('name') as string
  await db.products.update({ where: { id }, data: { name } })
  revalidateTag('products')
}

The action runs server-side, updates the database, and invalidates the cache. The client component that called it receives the updated data on the next render — no manual state management required.

Fetching in Layouts vs Pages

Layouts can fetch data too. A layout wrapping all dashboard routes can fetch the current user once, and that fetch is cached for the duration of the request. Pages within that layout do not need to re-fetch the user.

Be aware that layouts and pages render in parallel in the App Router. A layout fetch does not block a page fetch. If a page also needs the user, call the same cached function — the request is deduped automatically.

Error Handling

Wrap your data-fetching components with error.tsx at the appropriate route segment. If a fetch throws, the error boundary catches it and renders a recovery UI instead of crashing the whole page.

For expected empty states (a product that does not exist), use notFound() from next/navigation inside the server component. Next.js renders the nearest not-found.tsx file.


Test loading, errors, and freshness separately. A faster initial render does not establish that cached data updates when it should.

Discuss Next.js data fetching with LinkIT.