Data Fetching in React and Next.js, Compared on One App

Fetch on the server when you can: in the Next.js App Router an async server component awaits data before any HTML is sent, with no loading spinner code. For data that changes while the page is open, use a client cache such as TanStack Query or SWR, which handle caching, retries and mutations. A bare useEffect fetch suits one-off requests.

Which approach fits
ApproachRuns whereCachingBest for
Async server componentServerNot cached by default; opt in with use cachePage data, secrets, database queries
TanStack QueryBrowserQuery cache keyed by queryKeyDashboards, mutations, paging
SWRBrowserCache keyed by the request keyRead-heavy views that should stay fresh
useEffect + fetchBrowserNone unless you write itOne small request in a small app

Updated

Every example on this page loads and edits the same small notes list, so the differences come from the approach rather than the demo. Answer the three questions below for a starting recommendation and the snippet that matches it.

Pick an approach

// app/notes/page.tsx (server component)
export default async function NotesPage() {
  const res = await fetch("https://api.example.com/notes");
  if (!res.ok) throw new Error("Could not load notes");
  const notes: { id: string; text: string }[] = await res.json();
  return <ul>{notes.map((n) => <li key={n.id}>{n.text}</li>)}</ul>;
}
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

const getNotes = () => fetch("/api/notes").then((r) => r.json());
const addNote = (text: string) =>
  fetch("/api/notes", { method: "POST", body: JSON.stringify({ text }) });

export function Notes() {
  const queryClient = useQueryClient();
  const { data, isPending, error } = useQuery({ queryKey: ["notes"], queryFn: getNotes });
  const add = useMutation({
    mutationFn: addNote,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["notes"] }),
  });
  if (isPending) return <p>Loading...</p>;
  if (error) return <p>Could not load notes</p>;
  return (
    <>
      <ul>{data.map((n: { id: string; text: string }) => <li key={n.id}>{n.text}</li>)}</ul>
      <button onClick={() => add.mutate("New note")}>Add</button>
    </>
  );
}
"use client";
import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function Notes() {
  const { data, error, isLoading, mutate } = useSWR("/api/notes", fetcher);
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Could not load notes</p>;
  async function add() {
    await fetch("/api/notes", { method: "POST", body: JSON.stringify({ text: "New note" }) });
    mutate(); // mark the cached data stale and refetch it
  }
  return (
    <>
      <ul>{data.map((n: { id: string; text: string }) => <li key={n.id}>{n.text}</li>)}</ul>
      <button onClick={add}>Add</button>
    </>
  );
}
"use client";
import { useEffect, useState } from "react";

export function Notes() {
  const [notes, setNotes] = useState<{ id: string; text: string }[] | null>(null);
  useEffect(() => {
    let ignore = false;
    fetch("/api/notes").then((r) => r.json()).then((d) => { if (!ignore) setNotes(d); });
    return () => { ignore = true; };
  }, []);
  if (!notes) return <p>Loading...</p>;
  return <ul>{notes.map((n) => <li key={n.id}>{n.text}</li>)}</ul>;
}

Defaults that surprise people

Out of the box behaviour
BehaviourNext.js server fetchTanStack QuerySWR
Cached across requestsNo, opt in with use cacheIn memory, per browser tabIn memory, per browser tab
Duplicate requestsMemoized within one renderShared by queryKeyDeduplicated by key within 2 seconds
Refetch on window focusNot applicableYes, when staleYes
Refetch on reconnectNot applicableYes, when staleYes
Retries on failureHandle it yourself3, with exponential backoffYes (errorRetryInterval 5000 ms)
Unused data kept forNot applicable5 minutes (gcTime)Not a documented default

Avoiding waterfalls on the server

Start both requests, then await them together

Calling both functions before awaiting means neither waits for the other.

export default async function Dashboard() {
  const notesP = fetch("https://api.example.com/notes").then((r) => r.json());
  const tagsP = fetch("https://api.example.com/tags").then((r) => r.json());
  const [notes, tags] = await Promise.all([notesP, tagsP]);
  return <p>{notes.length} notes, {tags.length} tags</p>;
}

Stream a slow part with Suspense

The heading is sent at once; the notes list streams in when its data arrives.

import { Suspense } from "react";

async function NoteList() {
  const notes = await fetch("https://api.example.com/notes").then((r) => r.json());
  return <ul>{notes.map((n: { id: string; text: string }) => <li key={n.id}>{n.text}</li>)}</ul>;
}

export default function Page() {
  return (
    <main>
      <h1>Notes</h1>
      <Suspense fallback={<p>Loading notes...</p>}>
        <NoteList />
      </Suspense>
    </main>
  );
}

Supabase in a server component

This uses the createClient helper from Supabase's Next.js server-side guide (lib/supabase/server.ts, built on @supabase/ssr). getClaims() verifies the signed-in user's token before the query runs.

Signed-in user's notes, loaded on the server

Row level security on the notes table decides which rows come back.

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export default async function MyNotes() {
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  if (!auth?.claims) redirect("/login");

  const { data: notes, error } = await supabase.from("notes").select("id, text");
  if (error) throw error;
  return <ul>{notes.map((n) => <li key={n.id}>{n.text}</li>)}</ul>;
}

Server first, then add a client cache where needed

A server component can query a database or call an API with credentials directly, and the result arrives as HTML. That removes loading state code from most pages and keeps keys out of the browser bundle.

Interactive screens are different. A notes list that people add to, filter and revisit benefits from a client cache that remembers results between screens and refreshes them in the background.

Streaming instead of waiting

If one request is slow, wrap the component that needs it in Suspense with a fallback. The rest of the page is sent immediately and the slow part streams in. A loading.js file does the same for a whole route segment in Next.js.

In a client component you can also receive a promise from the server and read it with React's use API inside Suspense.

Why the plain useEffect pattern falls short

A fetch in useEffect has no cache, so every mount repeats the request, and it needs manual handling of race conditions, loading and errors. It is fine for one small widget. For anything larger, a library gives you all of that for a few lines; the basics of fetch itself are covered in our async and fetch guide.

How it works

  1. Say whether you use a framework with server components, such as the Next.js App Router.
  2. Say whether the data must stay fresh while the page is open, and whether users edit it.
  3. Read the recommendation and copy the matching snippet.
  4. Use the comparison sections below to check defaults before you ship.

Tips

Frequently asked questions

Are fetch requests cached in the Next.js App Router?

According to the current Next.js documentation, fetch requests are not cached by default and block rendering until they finish. Identical requests in one render are memoized, and you opt into caching with the use cache directive or stream fresh data inside Suspense.

What are TanStack Query's important defaults?

Data is stale immediately (staleTime 0), so it refetches on mount, window focus and reconnect. Unused queries stay in memory for 5 minutes (gcTime), and failed queries retry 3 times with exponential backoff. Raise staleTime if that refetching is more than you need.

TanStack Query or SWR?

Both cache by key and revalidate on focus and reconnect. TanStack Query ships more built-ins for mutations, invalidation and devtools; SWR has a smaller API built around useSWR(key, fetcher). Pick one per app; running both duplicates the cache.

Do I still need a client library with server components?

Often not for page loads. You need one when data must refresh without navigation, when users edit and expect immediate feedback, or for infinite lists. Many apps load on the server and use a client cache only on interactive screens.

How should I check who is signed in with Supabase on the server?

Supabase's server-side guide says never to trust getSession() inside server code. Call supabase.auth.getClaims(), which verifies the token's signature, then query your tables; row level security still applies to every query.

Related: JavaScript async and fetch, React Server Components, Next.js App Router, Next.js authentication, React with TypeScript, Next.js vs React.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap