Web Development
Next.jsReact QuerySWRApp RouterRSCCachingPerformance

React Query vs SWR in Next.js App Router: When to Use Which (and How to Avoid Double Fetching)

AO
Adrijan Omićević
·14 min read

# The Current Landscape#

Next.js App Router changed how teams think about data fetching: React Server Components run on the server, Client Components run in the browser, and Next.js fetch adds its own caching and deduping on top. That creates a common problem: you render data on the server, then your client cache library fetches the same data again after hydration.

This post compares React Query vs SWR in Next.js App Router with a focus on real production concerns: caching behavior, SSR and RSC compatibility, mutations, optimistic updates, developer experience, and concrete patterns to avoid double fetching.

ℹ️ Note: This comparison assumes Next.js App Router with React Server Components enabled, and modern versions of both libraries. For deeper RSC fundamentals, read React Server Components guide. For Next.js caching nuances and tradeoffs, see Next.js caching strategies SSR ISR SWR.

# Quick Comparison Table#

CriteriaReact Query (TanStack Query)SWR (Vercel)
Primary modelQuery cache with explicit keys and invalidationStale-while-revalidate key cache with revalidation
Best atComplex server state, dashboards, SaaS CRUD, optimistic updatesSimple read-heavy fetching, small apps, content widgets
MutationsFirst-class useMutation, retries, invalidation pipelinesmutate and mutation helpers, more manual orchestration
Optimistic updatesStrong, well-documented patternsPossible, but usually more custom logic
RSC compatibilityUse in Client Components, hydrate from serverUse in Client Components, provide fallback data
Avoid double fetchingPrefetch server side and dehydrate, or pass initialDataPass fallback via provider, or avoid server fetch for same key
DevtoolsExcellentMinimal
EcosystemHuge, multi-framework, strong patternsLean, smaller surface area
Learning curveMediumLow
Bundle size impactHigherLower

# How Caching Actually Works in App Router#

The confusion in App Router is that there are multiple caches in play:

  1. 1
    Next.js fetch cache on the server: caches per request or per route segment depending on cache and next.revalidate.
  2. 2
    RSC render deduping: repeated fetch calls with the same input can be deduped during a render.
  3. 3
    Client cache: React Query or SWR caches data in the browser across renders, navigations, and refocus events.

Double fetching happens when you use both server fetch and client fetch for the same resource without hydrating the client cache.

The double fetching scenario in one sentence#

You fetch on the server for the initial HTML, then your Client Component mounts and fetches again because its cache is empty.

This is not only wasted bandwidth. It can add 100 ms to 500 ms of extra latency on slow networks and cause UI flicker when data changes between server render and client revalidate. For performance fundamentals and profiling, see website performance optimization.

# Caching Models: React Query vs SWR#

React Query cache model#

React Query treats server state as a normalized cache indexed by query keys. You control freshness using staleTime, and you control refetch behavior on focus, reconnect, and mount. You also get explicit invalidation and refetching via queryClient.invalidateQueries.

Practical implications:

  • If your dashboard has 20 widgets and they share queries, React Query gives you predictable deduping and invalidation.
  • If you update one entity, you can invalidate related queries by partial keys and let React Query refetch only what is stale.

SWR cache model#

SWR implements stale-while-revalidate: return cached data immediately, then revalidate in the background. It is intentionally minimal: a key, a fetcher, and revalidation controls. It excels when you want quick, simple client caching with lightweight configuration.

Practical implications:

  • Great for simple pages and small client widgets that can revalidate opportunistically.
  • For complex invalidation graphs, you usually end up calling mutate across multiple keys manually or writing helper utilities.

🎯 Key Takeaway: React Query gives you stronger primitives for large query graphs and invalidation; SWR stays simpler but pushes more orchestration to your app once complexity grows.

# SSR and RSC Compatibility in Next.js App Router#

Neither React Query nor SWR should be used directly inside Server Components. App Router expects you to fetch on the server using fetch or server actions, then pass data to Client Components.

  • Server Components: fetch with Next.js fetch and route segment caching.
  • Client Components: use React Query or SWR for interactivity, background refetch, pagination, mutations, and optimistic updates.
  • Avoid duplicate fetch: hydrate client cache with server-fetched data.

React Query SSR hydration pattern for App Router#

The most reliable approach is: prefetch server-side, dehydrate, then hydrate in a Client Provider. Keep the code minimal and consistent.

TSX
// app/providers.tsx
"use client";
 
import { QueryClient, QueryClientProvider, HydrationBoundary } from "@tanstack/react-query";
import { useState } from "react";
 
export function Providers(props: { state: unknown; children: React.ReactNode }) {
  const [client] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={client}>
      <HydrationBoundary state={props.state}>{props.children}</HydrationBoundary>
    </QueryClientProvider>
  );
}
TSX
// app/page.tsx (Server Component)
import { dehydrate, QueryClient } from "@tanstack/react-query";
import { Providers } from "./providers";
 
async function getUser() {
  const res = await fetch("https://api.example.com/me", { cache: "no-store" });
  if (!res.ok) throw new Error("Failed");
  return res.json();
}
 
export default async function Page() {
  const qc = new QueryClient();
  await qc.prefetchQuery({ queryKey: ["me"], queryFn: getUser });
  const state = dehydrate(qc);
 
  return (
    <Providers state={state}>
      {/* Client component uses useQuery(["me"]) without refetching */}
    </Providers>
  );
}

Key points:

  • Your queryFn should call fetch with the correct caching semantics.
  • Prefer cache: "no-store" for user-specific data, or a revalidate strategy for public data.
  • Set staleTime in the client query to avoid immediate refetch on mount.

SWR fallback pattern for App Router#

SWR provides fallback to seed the cache. The pattern is similar: fetch on the server, pass as fallback, then use useSWR in Client Components with the same key.

TSX
// app/swr-provider.tsx
"use client";
 
import { SWRConfig } from "swr";
 
export function SWRProvider(props: { fallback: Record<string, unknown>; children: React.ReactNode }) {
  return <SWRConfig value={{ fallback: props.fallback }}>{props.children}</SWRConfig>;
}
TSX
// app/page.tsx (Server Component)
import { SWRProvider } from "./swr-provider";
 
async function getUser() {
  const res = await fetch("https://api.example.com/me", { cache: "no-store" });
  if (!res.ok) throw new Error("Failed");
  return res.json();
}
 
export default async function Page() {
  const user = await getUser();
 
  return (
    <SWRProvider fallback={{ "/api/me": user }}>
      {/* Client component uses useSWR("/api/me") without refetching */}
    </SWRProvider>
  );
}

Key points:

  • The SWR key must match exactly, including query string and base path conventions.
  • If your client fetcher points to a different URL than the server fetch, you will still double fetch.

⚠️ Warning: The most common double-fetch bug is mismatched keys. A server fetch to https://api.example.com/me and a client fetch to /api/me are different caches and will both execute unless you unify the source or seed both.

# Mutations and Optimistic Updates#

Mutations are where the difference becomes most visible in real apps.

React Query mutations#

React Query has a dedicated mutation API with a clear lifecycle:

  • onMutate for optimistic updates
  • onError to rollback
  • onSuccess to invalidate or update queries
  • onSettled for cleanup

This is ideal for CRUD-heavy SaaS apps where perceived speed matters. A common product metric: even a 100 ms to 300 ms perceived improvement during frequent actions can reduce churn in internal tools, because the UI feels responsive.

Example optimistic update pattern:

TSX
"use client";
 
import { useMutation, useQueryClient } from "@tanstack/react-query";
 
export function useUpdateProfile() {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (payload: { name: string }) => {
      const res = await fetch("/api/profile", {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(payload),
      });
      if (!res.ok) throw new Error("Failed");
      return res.json();
    },
    onMutate: async (payload) => {
      await qc.cancelQueries({ queryKey: ["me"] });
      const prev = qc.getQueryData(["me"]);
      qc.setQueryData(["me"], (old: any) => ({ ...old, name: payload.name }));
      return { prev };
    },
    onError: (_err, _payload, ctx) => {
      qc.setQueryData(["me"], ctx?.prev);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["me"] });
    },
  });
}

SWR mutations#

SWR supports mutation via mutate and mutation helpers, and you can do optimistic updates. The difference is that you often write more glue code for rollback and for updating multiple keys.

A common approach is:

  • mutate(key, updater, false) to update locally without revalidate
  • run the request
  • revalidate or rollback

This is fine for a few endpoints. It becomes error-prone when you have multiple dependent caches, list and detail views, and pagination.

# Developer Experience and Team Workflow#

React Query DX#

React Query tends to win when a codebase grows:

  • Query key conventions scale well across teams.
  • Devtools make cache state and refetch reasons obvious.
  • Invalidation patterns are consistent, which reduces bugs during feature work.

The tradeoff is learning the mental model: staleTime vs cacheTime, invalidation semantics, and hydration boundaries.

SWR DX#

SWR tends to win for speed-to-ship:

  • Very low setup.
  • Minimal concepts.
  • Easy to sprinkle into small Client Components.

The tradeoff is that you will build your own conventions for keys, revalidation policies, and mutation patterns. Without strong conventions, larger teams end up with inconsistent caching behavior across the app.

💡 Tip: If you choose SWR for a growing app, define a single key builder utility early. Use one place to compose keys for list, detail, and filtered variants. This prevents 80 percent of accidental duplicate keys and invalidations later.

# Avoiding Double Fetching: Proven Patterns#

Double fetching is rarely a library bug. It is almost always an architectural mismatch.

Pattern A: Server fetch for first paint, hydrate client cache#

Use this when:

  • SEO or fast initial paint matters.
  • The same data is used immediately in a Client Component.
  • You want interactivity after hydration without refetching.

React Query: prefetchQuery plus dehydrate plus HydrationBoundary.

SWR: server fetch plus SWRConfig fallback.

Pattern B: Client-only fetch, no server fetch for that data#

Use this when:

  • Data is user-specific and not needed for initial HTML.
  • You want to avoid server load and keep the route static.
  • You can show skeletons or placeholders.

This is common for dashboards that render a frame quickly and load widgets progressively.

Pattern C: Let Next.js cache handle public content, use client cache sparingly#

For content sites:

  • Fetch content in Server Components using next.revalidate.
  • Avoid client caching libraries unless you have interactive widgets that need client-side revalidation.

This keeps bundle size smaller and reduces hydration work. It also aligns with App Router’s strengths.

Debug checklist for duplicate requests#

  1. 1
    Confirm whether the first request is server-side and the second is client-side.
  2. 2
    Verify key equality: exact SWR key string or React Query queryKey array.
  3. 3
    Verify URL equality: same path and query string, same base URL assumptions.
  4. 4
    Set staleTime in React Query to prevent immediate refetch after hydration.
  5. 5
    In SWR, disable revalidateOnMount when you truly want to trust fallback for a period.

# Recommendations by App Type#

Dashboards and internal tools#

Typical traits: lots of widgets, filters, pagination, frequent mutations, and data that changes often.

Recommendation:

  • Prefer React Query for predictable query graphs, deduping, and invalidation.
  • Use server rendering for layout and critical summary data only, then hydrate widgets or load client-only.

Why it matters:

  • Dashboards often trigger dozens of requests. React Query’s caching prevents repeated refetches when users navigate between tabs or open detail drawers.

SaaS products with CRUD and real-time-ish UX#

Typical traits: lists and detail views, forms, optimistic updates, multi-step flows, and high UX expectations.

Recommendation:

  • Prefer React Query for mutations and optimistic updates.
  • Use invalidateQueries strategically rather than global refetching.
  • Consider pairing with server actions for writes and React Query for client cache updates.

Why it matters:

  • Optimistic updates reduce perceived latency and make the product feel faster. React Query provides safer rollback patterns.

Content sites and marketing pages#

Typical traits: mostly read-only content, SEO-driven, relatively stable data, minimal client interactivity.

Recommendation:

  • Prefer SWR only for small client widgets such as a newsletter status check, pricing availability, or personalization flags.
  • For the core content, use Server Components and Next.js caching and skip client caching libraries in many cases.

Why it matters:

  • Bundle size and hydration time matter more than client caching sophistication on content pages.

# Migration Tips: SWR to React Query and React Query to SWR#

Migration is less about swapping hooks and more about adopting the caching model.

Migrating from SWR to React Query#

What changes:

  • Keys move from arbitrary strings to structured query keys, usually arrays.
  • Revalidation becomes invalidation and refetching, which is more explicit.
  • Mutations become useMutation with lifecycle handlers.

Practical steps:

  1. 1
    Create a query key convention, for example ["users", "list", filters] and ["users", "detail", id].
  2. 2
    Wrap the app in a single QueryClientProvider in app/providers.tsx.
  3. 3
    Replace critical SWR hooks first, starting with endpoints that cause the most double fetching or inconsistent updates.
  4. 4
    Replace SWR mutate calls with useMutation plus invalidateQueries.

Suggested mapping:

ConceptSWRReact Query
Read hookuseSWR(key, fetcher)useQuery({ queryKey, queryFn })
Global cache updatemutate(key)queryClient.invalidateQueries({ queryKey })
Optimistic updatemutate(key, data, false)onMutate plus setQueryData
Prefill from serverSWRConfig fallbackdehydrate plus HydrationBoundary

Migrating from React Query to SWR#

This usually happens when:

  • The app is read-heavy and you want less abstraction.
  • You want to reduce bundle size and mental overhead.
  • You rarely do mutations or can handle them in isolated places.

Practical steps:

  1. 1
    Audit query key usage and simplify into stable string keys.
  2. 2
    Replace invalidation patterns with explicit mutate calls on the relevant keys.
  3. 3
    Re-evaluate stale policies: SWR defaults may revalidate more often than you expect.
  4. 4
    If you relied on Devtools for debugging, add lightweight logging around your fetchers and mutation flows.

⚠️ Warning: If your app depends on complex invalidation, moving from React Query to SWR can increase bug risk. Teams often miss one of several related keys, leaving stale UI in edge flows like bulk edit or pagination.

# Pricing, Maintenance, and Ecosystem Fit#

Both libraries are mature and widely used.

  • SWR is maintained by Vercel and aligns well with the Next.js ecosystem.
  • React Query is part of TanStack and is widely used across React and beyond.

From a risk standpoint, both are safe choices. The deciding factors are app complexity and how much you want the library to manage for you.

# Key Takeaways#

  • Avoid double fetching by fetching once and hydrating the client cache: React Query uses dehydrate and HydrationBoundary, SWR uses SWRConfig fallback.
  • Choose React Query for dashboards and SaaS apps with frequent mutations, complex invalidation, and optimistic updates.
  • Choose SWR for simple read-heavy client fetching or small interactive widgets inside content-first Next.js pages.
  • Unify cache keys and URLs across server and client, otherwise hydration will not prevent duplicate requests.
  • In App Router, treat Server Components and Next.js fetch caching as the primary mechanism for initial data, and use client caching libraries for interactivity after hydration.

# Conclusion#

React Query and SWR both work in Next.js App Router, but they optimize for different realities. If your app is mutation-heavy or has a complex query graph, React Query will save engineering time and reduce stale UI bugs. If your needs are mostly read-only and you want the simplest tool that works, SWR is often the faster path.

If you want help choosing the right approach, fixing double fetching, or setting up a scalable caching strategy for App Router, contact Samioda and we will review your current data flow and propose a concrete implementation plan.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.