Data Fetching in Next.js

In the App Router data fetching happens directly in server components. No special functions like getServerSideProps or getStaticProps are needed. The component simply awaits the request.

Fetching Data in Server Components

// 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}</li>
      ))}
    </ul>
  )
}

A server component can directly access the database, file system or external APIs. Code and dependencies are not included in the client bundle.

fetch with Caching

Next.js extends the standard fetch by adding caching and revalidation:

// Cached by default (SSG equivalent)
const res = await fetch('https://api.hackfrontend.com/docs')

// No caching (SSR equivalent)
const res = await fetch('https://api.hackfrontend.com/feed', {
  cache: 'no-store'
})

// Time-based revalidation (ISR equivalent)
const res = await fetch('https://api.hackfrontend.com/problems', {
  next: { revalidate: 300 }
})

// Tag-based revalidation
const res = await fetch('https://api.hackfrontend.com/problems', {
  next: { tags: ['problems'] }
})

Parallel Data Fetching

A common mistake: sequential requests that could run in parallel.

// Bad: sequential requests
export default async function DashboardPage() {
  const user = await getUser()
  const stats = await getStats()      // waits for getUser
  const activity = await getActivity() // waits for getStats
  // ...
}

// Good: parallel requests
export default async function DashboardPage() {
  const [user, stats, activity] = await Promise.all([
    getUser(),
    getStats(),
    getActivity()
  ])
  // ...
}

Pattern: Data Preloading

For parallel loading at the component tree level use the preload pattern:

// lib/problems.ts
import { cache } from 'react'

export const getProblems = cache(async () => {
  const res = await fetch('https://api.hackfrontend.com/problems')
  return res.json()
})

export function preloadProblems() {
  void getProblems()
}
// app/problems/page.tsx
import { getProblems, preloadProblems } from '@/lib/problems'
import { ProblemList } from './problem-list'

export default async function ProblemsPage() {
  preloadProblems()
  return <ProblemList />
}

React.cache guarantees the request runs only once, even if getProblems is called in multiple places.

Client-Side Data Fetching

For interactive scenarios (search, filtering) data is loaded on the client:

'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()
  }, [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>
  )
}

Practical tip:

Load initial data on the server and pass it to client components via props. Use client-side fetching only for data that depends on user actions.

Useful Resources