GetLaunchpad
Back to blog
7 min read

Data fetching in Next.js App Router: the complete guide

Server components, React cache, Suspense streaming, parallel fetching with Promise.all, loading.tsx skeletons, and when to fetch client-side — everything you need to fetch data correctly in App Router.

Data fetching in Next.js App Router is fundamentally different from the Pages Router. There's no getServerSideProps or getStaticProps. Instead, server components fetch data directly, and the caching behavior is controlled through fetch options and React cache.

Here's how it actually works — including the patterns that aren't obvious from the docs.

Server components fetch directly

The most important shift: server components are async. You await data inside the component itself, with no data-fetching lifecycle to manage.

// app/(dashboard)/dashboard/page.tsx
import { auth } from "@clerk/nextjs/server";
import { getUserDbId } from "@/lib/db";
import { adminClient } from "@/lib/supabase/admin";

export default async function DashboardPage() {
  const { userId } = await auth();
  const dbUserId = await getUserDbId(userId!);

  const { data: projects } = await adminClient
    .from("projects")
    .select("*")
    .eq("user_id", dbUserId)
    .order("created_at", { ascending: false });

  return (
    <div>
      {projects?.map((project) => (
        <div key={project.id}>{project.name}</div>
      ))}
    </div>
  );
}

This runs on the server on every request. No API route needed. No useEffect. No loading state in the component. The data is ready before the HTML reaches the browser.

Caching: the thing everyone gets wrong

Next.js App Router caches fetch() calls by default. This is useful for public data (blog posts, pricing) but dangerous for user-specific data (dashboard, subscription status).

For Supabase queries (which use the Supabase SDK, not raw fetch), there is no automatic Next.js caching — each request hits the database. This is the correct behavior for user data.

For raw fetch() calls, opt out of caching when the data must be fresh:

// No cache — always fresh (replaces getServerSideProps)
const res = await fetch("https://api.example.com/data", {
  cache: "no-store",
});

// Revalidate every hour
const res = await fetch("https://api.example.com/data", {
  next: { revalidate: 3600 },
});

// Cache forever (static, like getStaticProps)
const res = await fetch("https://api.example.com/data", {
  cache: "force-cache",
});

Parallel data fetching

Don't await sequentially when fetches are independent — use Promise.all:

// ❌ Sequential — each waits for the previous
const user = await getUser(userId);
const subscription = await getSubscription(userId);
const projects = await getProjects(userId);

// ✅ Parallel — all run simultaneously
const [user, subscription, projects] = await Promise.all([
  getUser(userId),
  getSubscription(userId),
  getProjects(userId),
]);

Sequential fetches add latency for every additional query. Parallel fetches take the time of the slowest single query.

React cache for deduplication

If the same data is fetched in multiple server components during the same render, use React's cache() to deduplicate:

// lib/db.ts
import { cache } from "react";
import { adminClient } from "@/lib/supabase/admin";

export const getUserDbId = cache(async (clerkId: string): Promise<string | null> => {
  const { data } = await adminClient
    .from("users")
    .select("id")
    .eq("clerk_id", clerkId)
    .single();
  return data?.id ?? null;
});

With cache(), if getUserDbId(userId) is called in the layout and again in the page component, the database query only runs once.

Streaming with Suspense

Wrap slow data fetches in <Suspense> to stream partial HTML while the slow part loads:

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      {/* Fast parts render immediately */}
      <DashboardHeader />

      {/* Slow parts stream in when ready */}
      <Suspense fallback={<ProjectsSkeleton />}>
        <ProjectsList />
      </Suspense>
    </div>
  );
}

async function ProjectsList() {
  const projects = await getProjects(); // slow DB query
  return <ul>{projects.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Without Suspense, the entire page waits for the slowest fetch. With Suspense, users see the fast parts immediately while the slow parts load in.

loading.tsx for page-level skeletons

Add a loading.tsx file next to your page.tsx to show a skeleton while the server component fetches data:

// app/(dashboard)/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-4 animate-pulse">
      <div className="h-8 w-48 rounded bg-zinc-200 dark:bg-zinc-800" />
      <div className="grid gap-4 sm:grid-cols-3">
        {[1, 2, 3].map((i) => (
          <div key={i} className="h-32 rounded-xl bg-zinc-100 dark:bg-zinc-800/50" />
        ))}
      </div>
    </div>
  );
}

Next.js automatically wraps your page in a Suspense boundary and shows loading.tsx while the async page component awaits its data.

Client-side fetching when you need it

Server components can't respond to user interactions. For data that changes based on user actions (search results, filters, real-time updates), fetch client-side with useEffect or SWR:

"use client";

import { useEffect, useState } from "react";

export function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) return;
    setLoading(true);
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((r) => r.json())
      .then((data) => {
        setResults(data.results);
        setLoading(false);
      });
  }, [query]);

  if (loading) return <div>Searching…</div>;
  return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}

The right tool for each case

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles