Why Data Fetching Became Complex
In the early React era, fetching data meant calling fetch inside a useEffect, setting some state, and rendering the result. The mental model was simple. The problems came at scale.
Waterfall requests. Client-side fetching is inherently sequential. A component renders, discovers it needs data, fires a network request, waits for the response, then its children render and fire their own requests. Each round trip adds latency at the exact moment the user is already staring at a loading spinner.
State explosion. Every fetched resource requires its own isLoading, error, and data state. Across a large application this created dozens of nearly identical patterns, all managed manually, all prone to subtle bugs like forgetting to reset loading state on error or missing cleanup on unmount.
Caching as an afterthought. Without a dedicated layer, the same data gets fetched repeatedly as users navigate between routes, components mount and unmount, or tabs regain focus. Every re-render could trigger a fresh network request.
Race conditions. A user types in a search box fast. Three requests fire. They resolve out of order. The UI shows stale data from an earlier query. This class of bug is invisible in development and routine in production without explicit request cancellation and cleanup.
The server-first shift. React Server Components and the Next.js App Router fundamentally changed the default. Data can now be fetched on the server, close to the data source, without any of the above problems. Client-side fetching is now reserved for genuinely interactive scenarios, not used as the default for everything.
Types of Data Fetching
Data fetching is not just “calling APIs.” It is a spectrum of strategies, each with different performance characteristics, caching behaviour, and appropriate use cases. Choosing the wrong strategy is one of the most common architectural mistakes in React applications.
The Core Distinction: Where Does the Fetch Run?
Server-Side Fetching
- Runs at request time or build time
- Data never touches the browser
- Can access secrets and databases directly
- Ships zero JavaScript to the client
- No loading states needed for initial data
- Inherently secure and private
Client-Side Fetching
- Runs in the browser after hydration
- Needed for user-triggered actions
- Required for realtime and polling
- Appropriate for personalised data post-auth
- Supports optimistic UI patterns
- Managed via TanStack Query, SWR, etc.
The Six Fetching Strategies
Static (SSG)
Build TimeData fetched at build time. HTML is pre-rendered and served from CDN. Zero server cost per request. Best for content that changes rarely.
SSR
Request TimeData fetched on the server for each request. Always fresh. Best for personalised or frequently changing content.
ISR
HybridStatic HTML with background revalidation. Serves cached content instantly while regenerating stale pages in the background.
RSC Fetch
Server ComponentAsync Server Components fetch data directly using await fetch() or ORM calls. The modern default for initial data in Next.js App Router.
Client Fetch
BrowserTanStack Query or SWR manages fetching in the browser. Required for interactive data, user-triggered updates, and realtime scenarios.
Streaming
ProgressiveServer streams HTML progressively as data resolves. Suspense boundaries define loading states. Fast TTFB with progressive enhancement.
Server State vs Client State
This distinction is foundational. Server state is data that lives on a server, is asynchronous, can become stale, and needs to be synchronised with the backend. Client state is ephemeral UI state that lives only in the browser: modal open/closed, form draft, selected tab.
Traditional useEffect Fetching
Before RSC and dedicated data-fetching libraries, the dominant pattern was triggering a fetch call inside useEffect. It works for simple cases and remains acceptable in small isolated components, but it has compounding problems at scale.
"use client";
import { useState, useEffect } from "react";
interface Post {
id: number;
title: string;
body: string;
}
export function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetch("/api/posts")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
})
.then((data) => {
setPosts(data);
setIsLoading(false);
})
.catch((err) => {
setError(err.message);
setIsLoading(false);
});
}, []);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}Why This Pattern Breaks at Scale
- Race conditions. If props change quickly (a search input), multiple requests fire concurrently and may resolve out of order. The above code has no cleanup or abort logic. The result shown may correspond to a stale query.
- No caching. Every component mount triggers a fresh request, even if identical data was fetched 200ms ago in a sibling component.
- Waterfall rendering. Parent fetches, renders, children discover they also need data and fetch in turn. Each level of the tree adds a full round-trip before rendering can complete.
- State boilerplate. Every data requirement adds three state variables:
isLoading,error,data. A component fetching four resources has twelve state variables. - Memory leaks. If the component unmounts before the fetch resolves, calling
setStateon an unmounted component logs a warning (React 18) or silently does nothing (React 19), but it indicates missing cleanup.
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError(null);
fetch(`/api/posts?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
setPosts(data);
setIsLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
setError(err.message);
setIsLoading(false);
}
});
// Cleanup: abort previous request when query changes
return () => controller.abort();
}, [query]);Server-Side Data Fetching
Server-side fetching is the practice of retrieving data on the server before sending a response to the browser. In the Next.js App Router with React Server Components, this is the default. It eliminates the entire class of client-side fetching problems: no race conditions, no caching gaps, no loading spinners for initial data, no bundle size impact.
Direct Database Access in Server Components
Unlike the pages directory where you fetched from your own API routes, Server Components can query databases directly. The component runs on the server and never sends database credentials or query logic to the browser.
import { db } from "@/lib/db"; // Prisma, Drizzle, etc.
// This is a Server Component: runs entirely on the server
export default async function PostsPage() {
// Direct database query. No API route. No client exposure.
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 20,
});
return (
<main>
<h1>Latest Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}Parallel Server Fetching
Server components can initiate multiple fetches in parallel using Promise.all. This is critical for avoiding waterfalls even on the server side.
export default async function DashboardPage() {
// Start all fetches concurrently: do NOT await each in sequence
const [user, stats, recentActivity] = await Promise.all([
getUser(),
getDashboardStats(),
getRecentActivity(),
]);
return (
<Dashboard user={user} stats={stats} activity={recentActivity} />
);
}
// Sequential (waterfall): AVOID this
async function DashboardPageSlow() {
const user = await getUser(); // waits
const stats = await getDashboardStats(); // waits after user resolves
const activity = await getRecentActivity(); // waits after stats resolves
// Total time = user + stats + activity (summed, not parallel)
}Authenticated Server Fetching
Server Components have access to request context, cookies, and headers. Auth-aware fetching is natural and secure.
import { cookies } from "next/headers";
import { verifySession } from "@/lib/auth";
export default async function AccountPage() {
const cookieStore = await cookies();
const session = await verifySession(cookieStore.get("session")?.value);
if (!session) {
redirect("/login");
}
// Query is scoped to the authenticated user: no client-side token exposure
const profile = await db.user.findUnique({
where: { id: session.userId },
select: { name: true, email: true, plan: true },
});
return <AccountSettings profile={profile} />;
}"use client". This means server fetching requires no extra configuration: just write async components and await your data.React Server Components & Data Fetching
React Server Components are not just a rendering optimisation. They are a data fetching model. The key insight: a component that fetches its own data and renders it, all on the server, produces zero client JavaScript and requires zero client-side loading state.
Async Components Are the API
// No hooks. No useEffect. No useState. No loading state.
// This component runs on the server and is never sent to the browser.
async function ProductDetails({ productId }: { productId: string }) {
// Awaiting directly in the component body
const product = await db.product.findUnique({
where: { id: productId },
include: { reviews: true, inventory: true },
});
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<PriceDisplay price={product.price} />
{/* AddToCartButton is "use client": it receives serialized product data */}
<AddToCartButton productId={product.id} name={product.name} />
</article>
);
}Request Deduplication with React cache()
When multiple Server Components in the same request tree fetch the same data, React's cache() function deduplicates the calls. The function runs once and the result is shared across all callers within the same request.
import { cache } from "react";
import { db } from "@/lib/db";
// Wrapped with cache(): called multiple times in the same request tree,
// but only executes the database query once per request
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});
// In your layout:
// const user = await getUser(session.userId);
// In your page (same request):
// const user = await getUser(session.userId); // uses cached resultSerialized Props: The Boundary Contract
Server Components pass data to Client Components as props. These props are serialized to JSON and sent over the wire. This means only JSON-serializable values can cross the boundary: strings, numbers, arrays, plain objects, andDate objects serialized as strings.
// Server Component: fetches data, passes to client component
async function OrderSummary({ orderId }: { orderId: string }) {
const order = await db.order.findUnique({
where: { id: orderId },
select: {
id: true,
total: true,
status: true,
items: { select: { name: true, quantity: true, price: true } },
},
});
// Only serializable data crosses the boundary
return <OrderClient order={order} />;
}
// Client Component: handles interactivity
"use client";
function OrderClient({ order }: { order: SerializedOrder }) {
const [expanded, setExpanded] = useState(false);
// ...
}fetch() Caching in Next.js App Router
Next.js extends the native fetch API with caching options. By default in Next.js 15, fetch results are NOT cached (consistent with per-request dynamic rendering). You explicitly opt into caching.
// No caching: fresh data every request (Next.js 15 default)
const data = await fetch("/api/prices");
// Cache indefinitely: revalidate on demand
const data = await fetch("/api/config", {
next: { tags: ["config"] },
});
// Time-based revalidation: re-fetch after 60 seconds
const data = await fetch("/api/exchange-rates", {
next: { revalidate: 60 },
});
// Force static: cache at build time, never re-fetch
const data = await fetch("/api/countries", {
cache: "force-cache",
});
// Revalidate the config tag when config changes
import { revalidateTag } from "next/cache";
export async function updateConfig() {
"use server";
await db.config.update(/* ... */);
revalidateTag("config"); // purges all fetch() calls tagged "config"
}Modern Client-Side Fetching
Client-side fetching is not obsolete. It remains the correct approach for a specific set of requirements: interactivity, user-triggered actions, realtime updates, optimistic UI, and post-authentication personalisation that cannot be statically determined.
When Client-Side Fetching Is Still Necessary
- User-triggered actions: search, filters, form submissions with immediate feedback, pagination clicks.
- Realtime data: live notifications, collaborative editing, chat, live sports scores: anything that requires pushing updates from the server.
- Polling: dashboards that auto-refresh every 30 seconds, job status indicators, monitoring dashboards.
- Optimistic UI: social likes, shopping cart updates, list reordering: showing the user their action succeeded instantly before the server confirms.
- Infinite scroll: feed-style UIs where the user loads more data on demand as they scroll.
- Highly personalised post-auth data: content specific to the logged-in user that varies per session and cannot be rendered at build time.
The Hybrid Architecture
Modern Next.js applications use both strategies simultaneously. Server Components handle initial data loading. Client Components with TanStack Query handle interactivity, mutations, and live updates. The key is choosing correctly per use case, not dogmatically applying one approach everywhere.
// Server Component: initial data, no client JS
async function InboxPage() {
const messages = await db.message.findMany({
where: { userId: session.userId, archived: false },
orderBy: { createdAt: "desc" },
});
// Pass initial data as prop to the client component
return <MessageList initialMessages={messages} />;
}
// Client Component: handles realtime polling and mutations
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function MessageList({ initialMessages }: { initialMessages: Message[] }) {
const queryClient = useQueryClient();
// initialData seeds the cache: no loading flash on first render
const { data: messages } = useQuery({
queryKey: ["messages"],
queryFn: () => fetch("/api/messages").then((r) => r.json()),
initialData: initialMessages,
refetchInterval: 30_000, // poll every 30s
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/messages/${id}/archive`, { method: "POST" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["messages"] }),
});
// ...
}TanStack Query / Server State Management
TanStack Query (formerly React Query) is the industry-standard library for managing server state in React applications. It is not a data-fetching library: it does not call your API. It is a server state synchronisation layer that manages the entire lifecycle of asynchronous data: fetching, caching, background updates, error retries, optimistic mutations, and cache invalidation.
cacheTime was renamed to gcTime, isLoading was split into isPending (no data yet) and isFetching (any background request in flight), and the object form is now required for most functions.Core Concepts
import { useQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
async function fetchPost(id: number): Promise<Post> {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error("Failed to fetch post");
return res.json();
}
function PostView({ postId }: { postId: number }) {
const {
data: post,
isPending, // true when no data in cache yet
isFetching, // true on any background fetch (includes refetches)
isError,
error,
} = useQuery({
queryKey: ["post", postId], // cache key: change to refetch
queryFn: () => fetchPost(postId),
staleTime: 1000 * 60 * 5, // consider data fresh for 5 minutes
gcTime: 1000 * 60 * 10, // keep unused cache for 10 minutes
});
if (isPending) return <Skeleton />;
if (isError) return <ErrorMessage message={error.message} />;
return <Article post={post} />;
}Mutations and Cache Invalidation
import { useMutation, useQueryClient } from "@tanstack/react-query";
function LikeButton({ postId, initialLiked }: { postId: number; initialLiked: boolean }) {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: (liked: boolean) =>
fetch(`/api/posts/${postId}/like`, {
method: liked ? "POST" : "DELETE",
}),
// Optimistic update: update UI before the request completes
onMutate: async (liked) => {
// Cancel any in-flight queries for this post
await queryClient.cancelQueries({ queryKey: ["post", postId] });
// Snapshot the previous value for rollback
const previous = queryClient.getQueryData(["post", postId]);
// Optimistically update the cache
queryClient.setQueryData(["post", postId], (old: Post) => ({
...old,
liked,
likeCount: old.likeCount + (liked ? 1 : -1),
}));
return { previous };
},
// Rollback on error
onError: (_err, _liked, context) => {
queryClient.setQueryData(["post", postId], context?.previous);
},
// Always refetch after settle to ensure server truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["post", postId] });
},
});
return (
<button onClick={() => likeMutation.mutate(!initialLiked)}>
{initialLiked ? "Unlike" : "Like"}
</button>
);
}staleTime vs gcTime
| Concept | What it controls | Default | Typical value |
|---|---|---|---|
| staleTime | How long cached data is considered fresh before a background refetch is triggered | 0ms (always stale) | 1–5 min for slow-changing data |
| gcTime | How long unused (no active observers) cached data is kept before garbage collection | 5 min | 5–30 min |
staleTime: Infinity makes data never go stale automatically. You take full control of invalidation via queryClient.invalidateQueries() or queryClient.setQueryData(). This is appropriate for reference data that only changes when a mutation occurs.Prefetching with Server Components
// app/posts/page.tsx: Server Component
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
export default async function PostsPage() {
const queryClient = new QueryClient();
// Pre-fetch on the server: seeds the client cache
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
// Transfers server-side cache state to the client
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsClient />
</HydrationBoundary>
);
}
// Client Component: cache is already populated, no loading flash
"use client";
function PostsClient() {
const { data: posts } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 1000 * 60,
});
// data is immediately available from the hydrated cache
}When NOT to Use TanStack Query
- Server Components fetching server-side data. You do not need TanStack Query in Server Components. Use
await fetch()or direct DB calls. TanStack Query is a client-side tool. - One-off form submissions with no refetch needs. A simple Server Action with
revalidatePathis cleaner. - Purely static content. If the data never changes after build time, ISR or static generation is the right choice.
SWR vs TanStack Query vs Native Fetch
Native fetch in RSC
Server-SideNo library. Direct await fetch() or ORM calls in async Server Components. Simplest API, zero bundle cost, full Next.js cache integration. Not applicable to client-side scenarios.
TanStack Query v5
Client-SideFull-featured server state library. Best-in-class caching, optimistic updates, background refetch, infinite queries, devtools. Steeper learning curve. The default choice for client-side data management in large applications.
SWR v2
Client-SideMinimal, focused on the stale-while-revalidate pattern. Smaller bundle, simpler API. Less capable than TanStack Query for complex cache management and mutations. Good default for simpler Vercel-based apps.
RTK Query
Client-SideRedux Toolkit's data-fetching layer. Good choice if your application already uses Redux extensively and you want server state integrated into the Redux store. Significant overhead for greenfield projects.
Caching Architecture
Modern Next.js applications operate across multiple caching layers simultaneously. Understanding which cache holds what data, and how each layer invalidates, is critical for building applications that are both fast and correct.
fetch() calls tagged with next.revalidate or next.tags. Survives process restarts on Vercel.Server · PersistentCache Invalidation Strategies
| Strategy | Mechanism | Use Case |
|---|---|---|
| Time-based (TTL) | next: { revalidate: 60 } or staleTime | Exchange rates, news feeds, pricing |
| On-demand tag purge | revalidateTag("tag") from Server Action | CMS content, product catalogue |
| Path revalidation | revalidatePath("/products") | After mutation that affects a specific route |
| Query invalidation | queryClient.invalidateQueries() | After client mutation: marks cache stale, triggers refetch |
| Optimistic update | queryClient.setQueryData() | Instant UI feedback before server confirmation |
Stale-While-Revalidate
The stale-while-revalidate pattern is the foundation of both SWR and TanStack Query's default behaviour. When cached data exists but is past its staleTime, the library immediately returns the stale data (instant UX) and simultaneously fires a background refetch. Once the refetch completes, the UI updates. Users get instant responses with eventually-consistent data.
Suspense & Streaming
React Suspense and streaming change how loading states work at an architectural level. Instead of managing isLoading flags inside each component, you declare loading boundaries in your component tree. React streams HTML progressively: fast sections appear immediately while slow sections show fallbacks and stream in as they resolve.
How Streaming Works in Next.js
When a Server Component is wrapped in Suspense, Next.js streams the page in two phases. The outer shell renders and is sent immediately (fast TTFB). When the suspended component resolves, React streams the completed HTML chunk to replace the fallback. The user sees content progressively rather than waiting for the slowest fetch to complete.
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div className="dashboard">
{/* Renders immediately: no data dependency */}
<DashboardHeader />
{/* Streams in when the slow analytics fetch resolves */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsPanel />
</Suspense>
{/* Renders in parallel: its own independent Suspense boundary */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
// Async Server Component: suspends while fetching
async function AnalyticsPanel() {
// This fetch can take 800ms: the rest of the page is already visible
const analytics = await db.analytics.getWeeklySummary();
return <AnalyticsChart data={analytics} />;
}
async function RecentActivity() {
const activity = await db.activity.getRecent({ limit: 10 });
return <ActivityFeed items={activity} />;
}loading.tsx: Route-Level Suspense
Next.js App Router treats loading.tsx files as automatic Suspense boundaries for the corresponding route segment. You do not need to manually wrap pages in Suspense: placing a loading.tsx file alongside your page.tsx achieves the same result at the route level.
// Automatically shown while products/page.tsx fetches its data
export default function ProductsLoading() {
return (
<div className="grid">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="skeleton-card" />
))}
</div>
);
}use() Hook for Client Components
React 19 ships the use() hook, which allows Client Components to consume a Promise and participate in Suspense. The promise can be created on the server and passed as a prop: the client component suspends until it resolves.
// Server Component: starts the fetch, does NOT await it
export default function ProductsPage() {
// Promise is created here but not awaited
const productsPromise = fetchProducts();
return (
<Suspense fallback={<ProductsSkeleton />}>
{/* Pass promise as prop: client component consumes it */}
<ProductsClient productsPromise={productsPromise} />
</Suspense>
);
}
// Client Component: suspends until the promise resolves
"use client";
import { use } from "react";
function ProductsClient({ productsPromise }: { productsPromise: Promise<Product[]> }) {
// Suspends the component until the promise resolves
const products = use(productsPromise);
return <ProductGrid products={products} />;
}Suspense boundary. If one section takes 2 seconds and another takes 200ms, the user sees the fast section in 200ms rather than waiting 2 seconds for everything.Performance Considerations
Eliminating Request Waterfalls
A waterfall is when sequential dependencies force requests to run one after another. Every level of sequential await that could be parallelised adds unnecessary latency.
// WATERFALL: each awaits the previous (total: sum of all durations)
async function SlowPage({ userId }: { userId: string }) {
const user = await getUser(userId); // 80ms
const posts = await getPosts(user.id); // 120ms
const friends = await getFriends(user.id); // 90ms
// Total: ~290ms
}
// PARALLEL: all start simultaneously (total: slowest duration)
async function FastPage({ userId }: { userId: string }) {
const [user, posts, friends] = await Promise.all([
getUser(userId), // 80ms
getPosts(userId), // 120ms: these don't depend on user
getFriends(userId), // 90ms
]);
// Total: ~120ms
}
// When there IS a real dependency, use initialise-then-await
async function DependentPage({ userId }: { userId: string }) {
const user = await getUser(userId); // must fetch first
// Start both in parallel once user is available
const [orders, preferences] = await Promise.all([
getOrders(user.accountId),
getPreferences(user.preferenceProfileId),
]);
}Prefetching
Next.js Link components automatically prefetch routes when they appear in the viewport in production. TanStack Query exposes queryClient.prefetchQuery() to pre-warm the client cache before the user navigates or interacts.
function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient();
return (
<article
onMouseEnter={() => {
// Pre-warm the cache when the user hovers
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () => fetchPost(post.id),
staleTime: 1000 * 60 * 5,
});
}}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</article>
);
}Bundle Size Impact
| Approach | Client bundle cost | Note |
|---|---|---|
| Server Component fetch | 0 KB | Runs entirely on server |
| useEffect + fetch (manual) | 0 KB | Native browser APIs |
| SWR v2 | ~4 KB gzip | Minimal footprint |
| TanStack Query v5 | ~13 KB gzip | Feature-rich; worth it at scale |
| RTK Query | ~45 KB+ gzip | Includes Redux + RTK |
Security Considerations
Server-Side Fetching Hides Secrets
Any API key, database credential, or auth token used in a Server Component never reaches the browser. This is architecturally safer than client-side fetching through a backend-for-frontend proxy, because there is no network leg that exposes the token pattern.
// Server Component: API key never leaves the server
async function WeatherWidget({ city }: { city: string }) {
const data = await fetch(
`https://api.weather.com/v1/current?city=${city}`,
{
headers: {
"X-API-Key": process.env.WEATHER_API_KEY!, // server-only env var
},
}
).then((r) => r.json());
return <Weather data={data} />;
}Never Expose Sensitive Data to Client Components
Server Components control what data crosses the server/client boundary. Only pass the fields a Client Component actually needs. Avoid passing entire database records as props: select specific fields and never pass password hashes, internal IDs, sensitive configuration, or anything the user should not see.
// Explicitly select only public fields before passing to client
async function UserProfilePage({ userId }: { userId: string }) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
name: true,
bio: true,
avatarUrl: true,
// password: false (default): never selected
// internalId: false: not included
// paymentMethodId: false: not included
},
});
return <ProfileClient profile={user} />;
}Validate User Identity for Every Server Fetch
Server Components run for every request. Authorization checks must happen in each component that accesses protected data. Do not rely on middleware alone: middleware can be bypassed or misconfigured. Defense in depth means checking identity at the data layer too.
async function OrderDetails({ orderId }: { orderId: string }) {
const session = await getSession();
if (!session) redirect("/login");
const order = await db.order.findUnique({ where: { id: orderId } });
// Verify the order belongs to the authenticated user
if (order?.userId !== session.userId) {
notFound(); // 404 rather than 403 to avoid enumeration
}
return <OrderView order={order} />;
}fetch() in a Server Component. An attacker could supply an internal URL (like http://localhost:6379 for Redis) and use your server as a proxy to scan internal services. Validate and allowlist URLs before fetching.Common Anti-Patterns
1. Fetching Everything in useEffect
Using useEffect for all data fetching was the 2018 answer. In a Next.js App Router application, it means opting out of server rendering, caching, and streaming for no benefit. Every component that fetches in useEffect contributes to client-side waterfalls and increases bundle size.
2. Duplicating Server State in Global Stores
Fetching from an API in a useEffect, then storing the result in Redux or Zustand, is a well-established anti-pattern. You now have a manual cache that you must invalidate, update on mutations, reset on logout, and synchronise with the server. TanStack Query or server-side fetching handles all of this correctly by design.
3. Overusing Client-Side Fetching
Most initial page data does not require client-side fetching. Product listings, blog posts, dashboard summaries, user profiles: all of these can be fetched in Server Components. Defaulting to client fetching for everything creates unnecessary loading states, waterfall requests, and inflated JavaScript bundles.
4. Giant Loading Spinners
A single Suspense boundary wrapping an entire page means the user sees nothing until every piece of data is ready. Use granular Suspense boundaries so fast sections appear immediately and slow sections stream in independently.
5. Unnecessary Polling
Setting refetchInterval: 5000on every query to keep data "fresh" burns API quota and creates unnecessary re-renders. Use polling only for genuinely realtime data. For most application data, background refetch on window focus and cache invalidation on mutation is sufficient.
6. Waterfall Component Fetching
Nesting async Server Components where each must wait for its parent is equivalent to sequential waterfall fetching. Hoist data fetching to the nearest common ancestor and pass data down as props, or use Promise.all to parallelise independent fetches.
7. No Stale Time on High-Frequency Queries
The default staleTime in TanStack Query is 0. This means every time a component mounts or a window regains focus, a background refetch fires. For data that changes infrequently, set an appropriate staleTime to prevent excessive requests.
Modern Architecture Patterns
Server Components + TanStack Query (Hybrid)
The dominant production pattern for interactive Next.js applications. Server Components handle initial data loading with zero client cost. TanStack Query manages mutations, background sync, and client-interactive features. The server pre-populates the TanStack Query cache via HydrationBoundary, so no loading state is shown on first render.
// app/tasks/page.tsx: Server Component
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function TasksPage() {
const queryClient = new QueryClient();
// Pre-fetch on server, hydrate client cache
await queryClient.prefetchQuery({
queryKey: ["tasks"],
queryFn: getTasks, // same function used client-side
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TasksClient />
</HydrationBoundary>
);
}
// components/tasks/TasksClient.tsx
"use client";
function TasksClient() {
const { data: tasks } = useQuery({
queryKey: ["tasks"],
queryFn: getTasks,
staleTime: 1000 * 30, // consider fresh for 30s
});
const createTask = useMutation({
mutationFn: (title: string) => fetch("/api/tasks", {
method: "POST",
body: JSON.stringify({ title }),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
});
// data is available immediately from hydrated cache
}Client Islands Architecture
Server Components render the page structure and content. Client Components are small, isolated "islands" of interactivity. Each island has its own hydration scope. This keeps the JavaScript payload minimal while enabling full interactivity where needed.
Optimistic UI with Rollback
For actions where latency is noticeable: likes, follows, task completion, cart updates: show the result immediately and roll back on failure. React 19's useOptimistic hook provides a first-class API for this pattern without requiring TanStack Query.
"use client";
import { useOptimistic, useTransition } from "react";
function TodoItem({ todo }: { todo: Todo }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodo, setOptimisticTodo] = useOptimistic(todo);
const toggle = () => {
startTransition(async () => {
// Update UI immediately
setOptimisticTodo({ ...todo, completed: !todo.completed });
// Perform server action
await toggleTodo(todo.id);
});
};
return (
<li style={{ opacity: isPending ? 0.7 : 1 }}>
<button onClick={toggle}>
{optimisticTodo.completed ? "✓" : "○"} {todo.title}
</button>
</li>
);
}Data Fetching by Application Type
Marketing Site
SSG + ISR
Build-time static generation for all pages. ISR for content that changes periodically (blog posts, pricing). Zero server cost per request. No client-side fetching needed.
SaaS Dashboard
RSC + TanStack Query
Server Components for initial page load. TanStack Query for user-interactive data, mutations, and background sync. HydrationBoundary to seed client cache from server. Suspense for streaming slow panels.
E-Commerce
SSG + SSR + TanStack Query
Product pages as ISR. Cart and checkout as SSR (always fresh). TanStack Query for wishlist, reviews, stock availability. Optimistic updates for cart mutations.
Realtime App
RSC + WebSocket + TanStack Query
Server Components for initial state. WebSocket or Server-Sent Events for live updates. TanStack Query for REST mutations. React state for immediate local updates before server confirmation.
Enterprise System
RSC + TanStack Query + RTK Query
RSC for server-side data. TanStack Query for independent data domains. RTK Query if already on Redux for consistency. Heavy use of cache invalidation, tag-based revalidation, and audit-driven data refresh.
Content Platform
ISR + RSC + SWR
ISR for published content pages. RSC for personalised feeds and recommendations. SWR for comment sections and like counts that update frequently. CDN-first caching strategy.
Comparison Table
| Feature | useEffect | RSC fetch | TanStack Query v5 | SWR v2 | RTK Query |
|---|---|---|---|---|---|
| Caching | None | Next.js Data Cache | Excellent | Good | Good |
| SSR Support | No | Native | Via hydration | Via fallback | Complex |
| Streaming | No | Yes (Suspense) | Partial | No | No |
| Learning Curve | Low | Low | Medium | Low | High |
| Performance | Poor | Excellent | Excellent | Good | Good |
| Scalability | Poor | Excellent | Excellent | Good | Excellent |
| Realtime | Manual | No | Polling + WS | Polling | Polling |
| Optimistic Updates | Manual | No | Built-in | Limited | Built-in |
| Developer Experience | Poor | Excellent | Excellent | Good | Complex |
| Bundle Size | 0 KB | 0 KB | ~13 KB gzip | ~4 KB gzip | ~45 KB+ gzip |
| Devtools | None | Next.js panel | Excellent | Limited | Redux DevTools |
Decision Framework
Use these rules as a starting point. Most applications use multiple strategies simultaneously: the goal is to apply each where it provides the most benefit.
The Default Stack (2026)
For a greenfield Next.js 15 application in 2026, the recommended defaults are:
- Initial page data: async Server Components with
awaitdirectly in the component body. - Slow sections: wrap in
Suspensewith a skeleton fallback to enable streaming. - Mutations and client interactions: TanStack Query v5 with
HydrationBoundaryto seed from server-side prefetch. - Cache invalidation after mutations: Server Actions +
revalidateTagfor server cache;invalidateQueriesfor client cache. - Form submissions: Server Actions for simple mutations; TanStack Query
useMutationfor complex flows needing optimistic updates or retry logic.
The Future of Data Fetching
Server-First Becomes the Default
The trajectory is clear: data fetching is moving to the server. React Server Components and the Next.js App Router have established server-side data access as the preferred default. Other frameworks are converging on this model. The era of fetching all data from the browser is over for most application types.
Smarter Caching at the Framework Level
Next.js has introduced cacheLife and cacheTag APIs for fine-grained per-function cache control. The direction is toward declarative caching policies that the framework optimises automatically, rather than manual cache header management.
Partial Prerendering (PPR)
Next.js Partial Prerendering, introduced experimentally in Next.js 14 and progressing toward stable, combines static and dynamic in a single route. The static shell is pre-rendered at build time and served instantly. Dynamic holes stream in at request time. This unifies SSG performance with SSR flexibility without configuration.
Reduced Client-Side Fetching
As RSC adoption matures, the total volume of client-side fetching in production applications will continue to decrease. TanStack Query will remain relevant for genuinely interactive patterns, but large categories of data that currently live in client-side caches will migrate to server-side fetching, reducing JavaScript bundle sizes and simplifying application state.
Edge Computing
Vercel Edge Runtime, Cloudflare Workers, and Deno Deploy enable server-side fetching to run at the network edge, close to the user. For read-heavy workloads with globally distributed users, edge-side data fetching provides near-CDN latency without sacrificing the ability to access dynamic data.
Data Fetching Without Next.js
Next.js provides a layered data fetching architecture: Server Components fetch data on the server, the Next.js Data Cache deduplicates and revalidates requests, and streaming Suspense delivers content progressively. Without Next.js, all of this moves to the browser. The patterns are well understood and libraries like TanStack Query make them ergonomic, but the mental model is different.
Setting Up a Vite + React + TypeScript Project
yarn create vite my-app --template react-ts
# npm create vite@latest my-app -- --template react-ts
cd my-app
yarn install
# npm install
yarn add @tanstack/react-query @tanstack/react-query-devtools
# npm install @tanstack/react-query @tanstack/react-query-devtools
yarn add @tanstack/react-router
# npm install @tanstack/react-routerNo RSC: All Data Fetching Happens in the Browser
The most fundamental difference: every data fetch in a Vite SPA is a client-side HTTP request. There is no server component that can call a database directly or access environment secrets at render time. All data comes over the network via your public or authenticated API.
| Next.js feature | Vite SPA equivalent |
|---|---|
| Async Server Component | useQuery from TanStack Query; loading state shown in browser |
Next.js Data Cache (fetch with cache option) | TanStack Query staleTime and gcTime in-memory cache |
ISR with revalidate | TanStack Query refetchInterval (client polling) or background refetch on focus |
| Streaming SSR with Suspense | Client-side Suspense boundaries work with TanStack Query suspense: true; no server streaming |
| Server Actions for mutations | useMutation calling a REST or tRPC endpoint |
revalidatePath / revalidateTag | queryClient.invalidateQueries({ queryKey: ['key'] }) |
TanStack Query: The Primary Pattern
TanStack Query is the closest SPA equivalent to what Next.js provides with Server Components and the Data Cache. It handles request deduplication, background revalidation, loading states, error boundaries, and optimistic updates:
import { useQuery } from "@tanstack/react-query";
interface Post { id: number; title: string; body: string; }
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
}
export function PostList() {
const { data: posts, isPending, isError } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error loading posts.</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Prefetching with TanStack Router Loaders
The main LCP problem with naive SPA data fetching is the waterfall: render the shell, then fetch data, then render content. TanStack Router loaders solve this by starting the fetch before the component renders, similar to how RSC fetches on the server before sending HTML:
import { createRoute } from "@tanstack/react-router";
import { queryClient } from "@/lib/queryClient";
import { fetchPosts } from "@/api/posts";
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/posts",
// Loader runs BEFORE the component renders, populating the cache
loader: () =>
queryClient.ensureQueryData({
queryKey: ["posts"],
queryFn: fetchPosts,
}),
component: PostList,
});
function PostList() {
// Data is already in cache from the loader -- no loading spinner needed
const { data } = useQuery({ queryKey: ["posts"], queryFn: fetchPosts });
return <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}Avoid useEffect for Data Fetching
The useEffect approach to data fetching creates race conditions, missing loading states, and no deduplication. It was common before TanStack Query and SWR became standard. Do not use it for any server state:
// Anti-pattern: useEffect data fetching
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/posts")
.then((r) => r.json())
.then(setPosts)
.finally(() => setLoading(false));
// Problems: no error handling, no deduplication, race condition on unmount,
// no background revalidation, no cache, triggers double-fetch in StrictMode
}, []);
...
}
// Use TanStack Query instead (shown above)Recommended Data Fetching Stack for Vite SPAs
Server State
- TanStack Query for all async data from APIs
- SWR as a lighter alternative (smaller bundle)
- tRPC for end-to-end type-safe APIs (TypeScript backend required)
Routing and Prefetching
- TanStack Router for type-safe routes with loader prefetching
- React Router v6 with
loaderfunctions as an alternative - Pair loaders with TanStack Query
ensureQueryData
Mutations
- TanStack Query
useMutationfor all write operations - Invalidate relevant query keys on success
- Optimistic updates with
onMutate/onErrorrollback
When to Add a Server
- When you need SSR for SEO or LCP on public pages
- When server-side secrets must never touch the browser
- When ISR or edge caching is required for dynamic content
- Consider Next.js, TanStack Start, or Remix at that point
@tanstack/query-persist-client-core plugin with a localStorage or IndexedDB adapter. This is optional; most apps do not need it.