Skip to main content
Engineering Reference · 2026

Data Fetching in React & Next.js

A complete architecture guide to modern data fetching. Covers server-side vs client-side strategies, RSC fetching, TanStack Query, caching layers, Suspense streaming, and production patterns for every application type.

~45 min readIntermediate to AdvancedReact 19 · Next.js 15 · TanStack Query v5
01 / Introduction

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.

The central insight of modern data fetching: most data in most applications does not need to be fetched in the browser. Fetching it on the server is faster, simpler, more secure, and requires zero client-side state management.
02 / Mental Model

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 Time

Data 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 Time

Data fetched on the server for each request. Always fresh. Best for personalised or frequently changing content.

ISR

Hybrid

Static HTML with background revalidation. Serves cached content instantly while regenerating stale pages in the background.

RSC Fetch

Server Component

Async Server Components fetch data directly using await fetch() or ORM calls. The modern default for initial data in Next.js App Router.

Client Fetch

Browser

TanStack Query or SWR manages fetching in the browser. Required for interactive data, user-triggered updates, and realtime scenarios.

Streaming

Progressive

Server 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.

The most common architectural mistake in React applications is storing server state in Redux, Zustand, or Context. These tools are designed for client state. Putting server state in them requires manual cache management, invalidation, loading states, and error handling that libraries like TanStack Query handle automatically and correctly.
03 / Traditional Pattern

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.

tsxThe classic pattern
"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 setState on an unmounted component logs a warning (React 18) or silently does nothing (React 19), but it indicates missing cleanup.
tsxRace condition fix with AbortController
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]);
When useEffect fetching is still acceptable: simple one-off requests in a small prototype, a single component that will never have siblings with overlapping data needs, or when you are intentionally avoiding a library dependency for a trivial use case. For production applications, use TanStack Query or server-side fetching instead.
04 / Server-Side Fetching

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.

tsxapp/posts/page.tsx: direct DB access
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.

tsxParallel fetching: no waterfall
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.

tsxAuth-aware server fetch
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} />;
}
Server-first is the default in Next.js App Router. Every component in the App Router is a Server Component unless you add "use client". This means server fetching requires no extra configuration: just write async components and await your data.
05 / RSC & Data Fetching

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

tsxAsync server component
// 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.

tslib/queries.ts: deduplicated fetches
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 result

Serialized 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.

tsxServer to client prop passing
// 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.

tsNext.js fetch caching options
// 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"
}
06 / Modern Client Fetching

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.

tsxHybrid: server loads initial data, client manages mutations
// 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"] }),
  });

  // ...
}
07 / TanStack Query

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.

TanStack Query v5 (2024+) shipped a streamlined API with some breaking changes from v4: 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

tsxBasic useQuery: v5 API
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

tsxuseMutation with optimistic update
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

ConceptWhat it controlsDefaultTypical value
staleTimeHow long cached data is considered fresh before a background refetch is triggered0ms (always stale)1–5 min for slow-changing data
gcTimeHow long unused (no active observers) cached data is kept before garbage collection5 min5–30 min
Setting 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

tsxHydrating TanStack Query from server
// 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 revalidatePath is cleaner.
  • Purely static content. If the data never changes after build time, ISR or static generation is the right choice.
08 / Tool Comparison

SWR vs TanStack Query vs Native Fetch

Native fetch in RSC

Server-Side

No 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-Side

Full-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-Side

Minimal, 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-Side

Redux 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.

09 / Caching Architecture

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.

Browser CacheHTTP cache (Cache-Control headers). Stores static assets, fonts, and fetch responses with cache headers.Client · HTTP headers
CDN / Edge CacheVercel Edge Network, Cloudflare, etc. Caches full route responses. Controlled by Cache-Control and Surrogate-Control headers.Global · Route-level
Next.js Data CachePersistent server-side cache for fetch() calls tagged with next.revalidate or next.tags. Survives process restarts on Vercel.Server · Persistent
React cache()Per-request memoization for Server Component data fetches. Deduplicates identical calls within a single server render pass. Discarded after each request.Request-scoped · In-memory
TanStack Query CacheClient-side in-memory cache keyed by query keys. Manages stale/fresh state, background refetches, and garbage collection for client components.Client · In-memory

Cache Invalidation Strategies

StrategyMechanismUse Case
Time-based (TTL)next: { revalidate: 60 } or staleTimeExchange rates, news feeds, pricing
On-demand tag purgerevalidateTag("tag") from Server ActionCMS content, product catalogue
Path revalidationrevalidatePath("/products")After mutation that affects a specific route
Query invalidationqueryClient.invalidateQueries()After client mutation: marks cache stale, triggers refetch
Optimistic updatequeryClient.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.

10 / Suspense & Streaming

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.

tsxapp/dashboard/page.tsx: streaming with Suspense
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.

tsxapp/products/loading.tsx
// 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.

tsxuse() hook: React 19
// 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} />;
}
Granular Suspense boundaries improve perceived performance. Wrap each independently-fetching section in its own 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.
11 / Performance

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.

tsxWaterfall vs parallel fetching
// 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.

tsxHover-to-prefetch with TanStack Query
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

ApproachClient bundle costNote
Server Component fetch0 KBRuns entirely on server
useEffect + fetch (manual)0 KBNative browser APIs
SWR v2~4 KB gzipMinimal footprint
TanStack Query v5~13 KB gzipFeature-rich; worth it at scale
RTK Query~45 KB+ gzipIncludes Redux + RTK
12 / Security

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.

tsSafe: credentials stay server-side
// 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.

tsxControlled data boundary
// 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.

tsxAuthorization at the data layer
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} />;
}
SSRF risk with user-controlled URLs. Never pass a user-supplied URL directly to 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.
13 / Anti-Patterns

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.

14 / Architecture Patterns

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.

tsxProduction hybrid pattern
// 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.

tsxuseOptimistic: React 19
"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>
  );
}
15 / By Application Type

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.

16 / Comparison Table

Comparison Table

FeatureuseEffectRSC fetchTanStack Query v5SWR v2RTK Query
CachingNoneNext.js Data CacheExcellentGoodGood
SSR SupportNoNativeVia hydrationVia fallbackComplex
StreamingNoYes (Suspense)PartialNoNo
Learning CurveLowLowMediumLowHigh
PerformancePoorExcellentExcellentGoodGood
ScalabilityPoorExcellentExcellentGoodExcellent
RealtimeManualNoPolling + WSPollingPolling
Optimistic UpdatesManualNoBuilt-inLimitedBuilt-in
Developer ExperiencePoorExcellentExcellentGoodComplex
Bundle Size0 KB0 KB~13 KB gzip~4 KB gzip~45 KB+ gzip
DevtoolsNoneNext.js panelExcellentLimitedRedux DevTools
17 / Decision Framework

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.

Ifdata is needed for initial page render and does not depend on browser stateRSC fetch
Ifcontent changes infrequently and SEO mattersSSG / ISR
Ifuser triggers a fetch (search, filter, load more, tab change)TanStack Query
Ifyou need optimistic updates, mutations with rollback, or complex cache invalidationTanStack Query
Ifyou need realtime push updates (live scores, chat, collaborative editing)WebSocket / SSE
Ifthe app already uses Redux and data needs to live in global storeRTK Query
Ifit's a small component with a one-off fetch and no re-fetch requirementsuseEffect + fetch
Ifmultiple slow sections should stream in independently without blocking each otherSuspense streaming

The Default Stack (2026)

For a greenfield Next.js 15 application in 2026, the recommended defaults are:

  • Initial page data: async Server Components with await directly in the component body.
  • Slow sections: wrap in Suspense with a skeleton fallback to enable streaming.
  • Mutations and client interactions: TanStack Query v5 withHydrationBoundary to seed from server-side prefetch.
  • Cache invalidation after mutations: Server Actions + revalidateTag for server cache; invalidateQueries for client cache.
  • Form submissions: Server Actions for simple mutations; TanStack Query useMutation for complex flows needing optimistic updates or retry logic.
18 / Future

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.

Pragmatic takeaway: the fundamentals do not change. Fetch data as close to where it is used as possible. Fetch as early as possible in the request lifecycle. Cache aggressively but invalidate correctly. Prefer the server for data that does not require browser context. Use libraries to manage the complexity of client-side server state rather than rebuilding it manually.
19 / Without Next.js

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

bashCreate a new Vite 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-router

No 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 featureVite SPA equivalent
Async Server ComponentuseQuery 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 revalidateTanStack Query refetchInterval (client polling) or background refetch on focus
Streaming SSR with SuspenseClient-side Suspense boundaries work with TanStack Query suspense: true; no server streaming
Server Actions for mutationsuseMutation calling a REST or tRPC endpoint
revalidatePath / revalidateTagqueryClient.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:

tsxBasic data fetching pattern with TanStack Query
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:

tsxTanStack Router loader: fetch before render
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:

tsxAnti-pattern: useEffect for data fetching
// 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 loader functions as an alternative
  • Pair loaders with TanStack Query ensureQueryData

Mutations

  • TanStack Query useMutation for all write operations
  • Invalidate relevant query keys on success
  • Optimistic updates with onMutate / onError rollback

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's in-memory cache is not persistent across page reloads by default. For offline support or persistence, add the @tanstack/query-persist-client-core plugin with a localStorage or IndexedDB adapter. This is optional; most apps do not need it.