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.
| Approach | Runs where | Caching | Best for |
|---|---|---|---|
| Async server component | Server | Not cached by default; opt in with use cache | Page data, secrets, database queries |
| TanStack Query | Browser | Query cache keyed by queryKey | Dashboards, mutations, paging |
| SWR | Browser | Cache keyed by the request key | Read-heavy views that should stay fresh |
| useEffect + fetch | Browser | None unless you write it | One 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.
// 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>;
}| Behaviour | Next.js server fetch | TanStack Query | SWR |
|---|---|---|---|
| Cached across requests | No, opt in with use cache | In memory, per browser tab | In memory, per browser tab |
| Duplicate requests | Memoized within one render | Shared by queryKey | Deduplicated by key within 2 seconds |
| Refetch on window focus | Not applicable | Yes, when stale | Yes |
| Refetch on reconnect | Not applicable | Yes, when stale | Yes |
| Retries on failure | Handle it yourself | 3, with exponential backoff | Yes (errorRetryInterval 5000 ms) |
| Unused data kept for | Not applicable | 5 minutes (gcTime) | Not a documented default |
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>;
}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>
);
}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.
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>;
}
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.
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.
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.
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.
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.
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.
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.
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.
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.