Next.js Data Fetching

Fetch data in Next.js using server components, static generation, ISR, and client fetching.

TL;DR

  1. 01Fetch data directly in server components with async/await.
  2. 02Use revalidate for static generation with periodic updates.
  3. 03ISR updates pages in background without full rebuild.

Tips

  1. 01Fetch data in server components whenever possible — it's faster and more secure than client-side fetching.

Warnings

  1. 01Don't fetch the same data multiple times — use fetch caching to reuse responses automatically.

Server Component Data Fetching

  • Fetch data directly in async server components.
    export default async function Page() {
      const res = await fetch('https://api.example.com/data');
      const data = await res.json();
      
      return <div>{data.title}</div>;
    }
  • Data is fetched on the server, only HTML is sent to browser.
  • Can access databases and secrets securely.
    export default async function Products() {
      const products = await db.query("SELECT * FROM products");
      return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
    }
  • Fetch multiple resources in parallel to reduce total wait time.
    export default async function Dashboard() {
      const [user, orders] = await Promise.all([getUser(), getOrders()]);
      return <div><UserCard user={user} /><OrderList orders={orders} /></div>;
    }
  • Next.js deduplicates identical fetch calls within the same request.
    // Both Header and Sidebar call getUser() — fetched only once
    async function Header() { const u = await getUser(); return <p>{u.name}</p>; }
    async function Sidebar() { const u = await getUser(); return <p>{u.role}</p>; }
  • Use error boundaries to handle failed fetch requests gracefully.
    // app/blog/error.tsx catches errors thrown during fetching
    export default function Error({ reset }) {
      return <button onClick={reset}>Retry</button>;
    }

Fetch Cache Configuration

  • In Next.js 15, fetch is uncached by default — opt in to caching explicitly.
    // Next.js 15 default: no caching — always fetches fresh data
    const res = await fetch('https://api.example.com/data');
    
    // Opt in to caching: revalidate at most every hour
    const res = await fetch('https://api.example.com/data', {
      next: { revalidate: 3600 }
    });
  • Use force-cache for data that should be cached indefinitely until manually invalidated.
    const res = await fetch('https://api.example.com/static', {
      cache: 'force-cache' // Cached until revalidatePath/revalidateTag is called
    });
  • Set revalidate at the route segment level to apply caching to the whole page.
    export const revalidate = 86400; // Re-render at most every 24 hours
    
    export default async function Page() {
      const data = await fetchStaticData();
      return <div>{data.content}</div>;
    }
  • Use next:{tags:[...]} to group cached fetches for tag-based invalidation.
    const res = await fetch("https://api/posts", {
      next: { revalidate: 3600, tags: ["posts"] }
    });
    // Call revalidateTag("posts") to invalidate all fetches tagged "posts"
  • Use unstable_cache to apply caching to non-fetch data like database queries.
    import { unstable_cache } from "next/cache";
    
    const getCachedUser = unstable_cache(
      async (id) => db.user.findUnique({ where: { id } }),
      ["user"],
      { revalidate: 3600 }
    );

Parallel and Sequential Fetching

  • Fetch multiple independent resources in parallel to avoid waterfall delays.
    export default async function Dashboard() {
      // Both requests fire at the same time
      const [user, orders] = await Promise.all([
        fetch("/api/user").then(r => r.json()),
        fetch("/api/orders").then(r => r.json())
      ]);
      return <div><UserCard user={user} /><OrderList orders={orders} /></div>;
    }
  • Use sequential awaits only when later data depends on earlier results.
    const user = await getUser(userId);
    const posts = await getPostsByAuthor(user.id); // depends on user.id
  • Wrap repeated data calls with React cache() so multiple components share one request.
    import { cache } from "react";
    
    export const getUser = cache(async (id: string) =>
      fetch(`/api/users/${id}`).then(r => r.json())
    );
    // Header and Sidebar both call getUser(id) — only one HTTP request is made
  • Use Promise.allSettled to fetch multiple resources without short-circuiting on error.
    const results = await Promise.allSettled([
      getUser(id),
      getRecommendations(id)
    ]);
    const user = results[0].status === "fulfilled" ? results[0].value : null;
  • Suspend individual slow sections with Suspense so fast data renders first.
    import { Suspense } from "react";
    
    export default function Page() {
      return (
        <div>
          <FastHeader />
          <Suspense fallback=<p>Loading...</p>>
            <SlowRecommendations />
          </Suspense>
        </div>
      );
    }

Dynamic Parameters with ISR

  • Generate pages for multiple parameters statically.
    export async function generateStaticParams() {
      const posts = await getPosts();
      return posts.map(p => ({ slug: p.slug }));
    }
    
    export const revalidate = 86400; // 1 day
    
    export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params; // params is a Promise in Next.js 15
      const post = await getPost(slug);
      return <article>{post.content}</article>;
    }
  • Pre-renders all post pages at build time.
  • Use dynamicParams to control behavior for unknown params.
    export const dynamicParams = true; // default: render on-demand for new params
    // Set to false to return 404 for params not in generateStaticParams
  • Pass fallback data while a new page generates for the first time.
    export const dynamic = "force-static"; // pre-render everything at build
  • Combine generateStaticParams with revalidation for hybrid pages.
    export const revalidate = 3600;
    export async function generateStaticParams() {
      const featured = await getFeaturedPosts();
      return featured.map(p => ({ slug: p.slug }));
      // Other slugs generate on first request, then are cached
    }
  • Use notFound() inside the page to handle deleted resources gracefully.
    const { slug } = await params;
    const post = await getPost(slug);
    if (!post) notFound(); // Returns 404 instead of a broken page

Client-Side Data Fetching

  • Fetch data on client with useEffect.
    "use client";
    
    import { useEffect, useState } from "react";
    
    export default function Component() {
      const [data, setData] = useState(null);
      
      useEffect(() => {
        fetch('/api/data')
          .then(r => r.json())
          .then(setData);
      }, []);
      
      return <div>{data}</div>;
    }
  • Use for real-time data or user-specific content.
  • Use SWR for built-in caching, revalidation, and error states — pass a fetcher that returns parsed JSON.
    "use client";
    import useSWR from "swr";
    
    const fetcher = (url: string) => fetch(url).then(r => r.json());
    
    export function Profile({ id }) {
      const { data, error, isLoading } = useSWR(`/api/users/${id}`, fetcher);
      if (isLoading) return <p>Loading...</p>;
      if (error) return <p>Error loading profile</p>;
      return <p>{data.name}</p>;
    }
  • Use React Query for more advanced caching and mutation workflows.
    "use client";
    import { useQuery } from "@tanstack/react-query";
    
    export function Posts() {
      const { data } = useQuery({ queryKey: ["posts"], queryFn: fetchPosts });
      return <ul>{data?.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
    }
  • Prefer server components over client fetching when data isn't user-specific.
    // Server component: no loading state, no useEffect needed
    export default async function PublicFeed() {
      const posts = await getPosts();
      return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
    }

FAQ