Skip to content

Next Data Fetching

Server Components fetch data on the server. Route handlers build APIs. Server Actions handle mutations. Three tools , three failure modes , one framework that blurs the line between them until you leak something

Data fetching in Next.js App Router is fundamentally different from any React framework before it. Your components can await fetch() directly in the render function. No useEffect. No Redux. No React Query. The server handles it all - unless you explicitly push data fetching to the client

fetching in Server Components

The simplest and most secure way to get data

// src/app/products/page.tsx
export default async function ProductsPage() {
  // This runs on the server only
  // Never ships to the client bundle
  const res = await fetch('https://api.example.com/products')
  const products = await res.json()

  return (
    <ul>
      {products.map((p: any) => (
        <li key={p.id}>{p.name} - ${p.price}</li>
      ))}
    </ul>
  )
}

Key behaviors: * Server-only - the fetch call , the API key , the database connection string never reach the browser * Automatic deduplication - same fetch URL called multiple times in the same render pass gets deduplicated * Caching - fetch responses are cached by default (change with cache or next.revalidate)

// caching options
fetch('https://api.example.com/data')                    // cached (SSG behavior)
fetch('https://api.example.com/data', { cache: 'no-store' }) // not cached (SSR behavior)
fetch('https://api.example.com/data', { next: { revalidate: 60 } }) // ISR with 60s revalidation

route handlers - the API layer

Sometimes you need a traditional API endpoint. That's what route handlers are for

// src/app/api/products/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const products = await prisma.product.findMany()
  return NextResponse.json(products)
}

export async function POST(request: Request) {
  const body = await request.json()
  const product = await prisma.product.create({ data: body })
  return NextResponse.json(product, { status: 201 })
}

Route handlers live in route.ts files inside the app/api/ directory

// src/app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const product = await prisma.product.findUnique({ where: { id } })

  if (!product) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  return NextResponse.json(product)
}

Route handlers support: * NextRequest - extends Request with cookies , geolocation , and URL parsing * NextResponse - extends Response with cookie and header manipulation * Dynamic segments - same [param] syntax as pages * Streaming - return a ReadableStream for large responses

Server Actions - mutations made simple

Server Actions are functions that run on the server but can be called from the client

// src/app/products/page.tsx
export default function ProductsPage() {
  async function createProduct(formData: FormData) {
    'use server'

    const name = formData.get('name')
    const price = formData.get('price')

    // validate on the server (not in the browser!)
    if (!name || !price) {
      throw new Error('Name and price required')
    }

    await prisma.product.create({
      data: { name, price: parseFloat(price as string) }
    })

    // revalidate the products page cache
    revalidatePath('/products')
  }

  return (
    <form action={createProduct}>
      <input name="name" placeholder="Product name" />
      <input name="price" placeholder="Price" type="number" step="0.01" />
      <button type="submit">Create</button>
    </form>
  )
}

The 'use server' directive marks this function as a Server Action. It receives FormData from the form submission and runs entirely on the server

revalidation strategies

After mutating data , you need to tell Next.js to refresh the cached data

import { revalidatePath } from 'next/cache'
import { revalidateTag } from 'next/cache'

// revalidate by path
revalidatePath('/products')        // revalidates the /products page
revalidatePath('/products/[id]')   // revalidates all dynamic product pages
revalidatePath('/api/products')   // revalidates the API route

// revalidate by tag
fetch('https://api.example.com/data', { next: { tags: ['products'] } })
// ...
revalidateTag('products') // revalidates all fetches tagged with 'products'

Tags are more precise. If only one product changes , you can revalidate just that tag instead of purging the entire path cache

error handling

Data fetching can fail. Handle it gracefully

// src/app/products/page.tsx
export default async function ProductsPage() {
  let products

  try {
    const res = await fetch('https://api.example.com/products')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    products = await res.json()
  } catch (error) {
    // Log server-side but show user-friendly message
    console.error('Failed to fetch products:', error)
    return <div>Failed to load products. Try again later</div>
  }

  return <ul>{/* render products */}</ul>
}

For more granular error handling , use error.tsx:

// src/app/products/error.tsx - client component
'use client'

export default function Error({
  error,
  reset
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something broke</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

The error.tsx file catches errors in Server Components and shows a UI. reset() re-renders the segment , potentially recovering from transient failures

prerequisites

next_05_rendering.md - you should understand SSR , SSG , and ISR strategies


next → next_07_server_actions.md