TanStack Query vs SWR: How to Choose in Next.js App Router

SitePoint TeamPublished inJavaScript·Web·
October 13, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
TanStack Query vs SWR Comparison
| Dimension | TanStack Query v5 | SWR 2.x |
|---|---|---|
| Bundle size (gzipped) | ~7 kB (core + useQuery/useMutation) | ~5.5 kB |
| Mutation support | Built-in useMutation with onMutate/onError/onSettled lifecycle | useSWRMutation (from swr/mutation) or manual fetch + mutate() |
| Optimistic rollback | Explicit onMutate→onError context flow; supports multi-query restoration | Declarative optimisticData + rollbackOnError; concise for simple toggles |
| DevTools | Official first-party devtools with cache timeline, query inspection, and manual invalidation | Community-built (swr-devtools), less feature-rich |
Most comparisons between TanStack Query and SWR were written for the Pages Router era, where every page relied on getServerSideProps or getStaticProps and client-side fetching libraries carried the full weight of data management. The Next.js App Router, with its Server Component defaults, fundamentally changes that calculus. This guide provides a scenario-based decision framework for choosing between TanStack Query and SWR in App Router projects, rather than rehashing feature checklists.
Table of Contents
Server Components vs. Client Components: The Data-Fetching Split
What Server Components Handle by Default
In the App Router, layouts and pages are Server Components by default. They can async/await data directly, calling databases, internal APIs, or third-party services without shipping any JavaScript to the browser. Server-fetched props and React.use() streaming cover page-initial data needs effectively (stable in React 19; canary in React 18). As the Next.js documentation states, Server Components render on the server and carry no client-side interactivity overhead, making them the correct default for any data you know at request time that does not depend on user interaction.
When You Still Need a Client-Side Library
Interaction-time data is where client-side libraries earn their place. Search filters, infinite scroll, modal-on-demand content, and any fetch that depends on user state or a browser event belongs in a Client Component with a fetching library. Content loaded only when a user opens a modal is a typical example. The rule of thumb: if the trigger is a click, a keystroke, or a scroll position, reach for a client library. If you know the data at request time, let a Server Component handle it and skip the library entirely.
The rule of thumb: if the trigger is a click, a keystroke, or a scroll position, reach for a client library. If you know the data at request time, let a Server Component handle it and skip the library entirely.
TanStack Query vs SWR at a Glance
API Philosophy and Bundle Size
SWR follows a minimal, hook-centric philosophy. At roughly 5.5 kB gzipped when bundled with esbuild (SWR 2.5), it prioritizes convention over configuration. A single useSWR hook handles the vast majority of use cases, and the API surface is deliberately small. TanStack Query takes the opposite approach: feature-rich and explicit, weighing in at roughly 7 kB gzipped for the client plus useQuery, useMutation and the query client (TanStack Query 5.104, bundled with esbuild, so your own number will vary with imports). It offers granular control over cache lifetime, retries, garbage collection, and query lifecycle. The gap is small, about 1-2 kB gzipped, so bundle size alone should rarely decide the choice.
Feature Parity and Divergence
Both libraries provide stale-while-revalidate caching, request deduplication, focus refetching, and SSR hydration support. Beyond that shared ground, they diverge.
TanStack Query provides built-in mutations via useMutation with onMutate/onError/onSettled lifecycle hooks, query cancellation, and select transformations for derived data. It also ships placeholderData for smooth transitions and official devtools with query state inspection, cache timeline visualization, and manual invalidation controls. SWR counters with a simpler mental model, useSWRInfinite for pagination, and a lighter dependency tree. SWR has useSWRMutation from swr/mutation for remote writes, but it is a thinner API with no onMutate context flow. The examples below use plain fetch plus mutate, which is the lowest-level option.
Scenario-to-Approach Decision Table
This table maps common data-fetching scenarios to the recommended approach and, where a client library is warranted, to the preferred option.
| Scenario | Recommended Approach | Preferred Client Library | Why |
|---|---|---|---|
| Static/SSG page data | Server Component fetch | Neither | No JS shipped to client |
| User-session dashboard (initial load) | Server Component + streaming | Neither | Render on server, stream to client |
| Search with filters/facets | Client Component | Either; TanStack Query edges out for complex key management | Query keys map naturally to filter state |
| Infinite scroll / pagination | Client Component | SWR (useSWRInfinite) or TanStack Query (useInfiniteQuery) | Both capable; SWR slightly simpler API |
| Modal-on-demand detail fetch | Client Component (lazy) | Either; SWR sufficient | Conditional fetch, small payload |
| Form submission + cache update | Client Component mutation | TanStack Query | Built-in useMutation with optimistic rollback |
| Real-time / polling data | Client Component | Either | Both support refreshInterval / refetchInterval |
| Offline-first with retry | Client Component | TanStack Query | Configurable retry and networkMode options (SWR also retries on error) |
This table is designed as a quick-reference starting point. The paired code examples below demonstrate three of the most critical patterns in practice.
Paired Code Examples: Three Critical Patterns
Prerequisites
These examples require Next.js 13.4+, React 18+, @tanstack/react-query ^5.0, swr ^2.0, and Node.js 18+. Install the client libraries:
npminstall @tanstack/react-query@^5 @tanstack/react-query-devtools@^5 swr@^2All code patterns assume stub API routes exist at /api/products, /api/reviews, and /api/products/[id] returning appropriately shaped JSON.
Each subsection below implements the same scenario side-by-side in TanStack Query and SWR inside a Next.js App Router Client Component using the "use client" directive.
Pattern 1: Filtered Query Keys
Scenario: A product catalog page where the user toggles category and sort filters.
"use client";import{ useQuery, keepPreviousData }from"@tanstack/react-query";import{ useState }from"react";asyncfunctionfetchProducts({ queryKey, signal }){const[,{ category, sort }]= queryKey;const params =newURLSearchParams({ category, sort });const res =awaitfetch(`/api/products?${params}`,{ signal });if(!res.ok)thrownewError("Failed to fetch products");return res.json();}exportdefaultfunctionProductCatalog(){const[category, setCategory]=useState("all");const[sort, setSort]=useState("newest");const{ data, isLoading, isFetching, isPlaceholderData, error }=useQuery({queryKey:["products",{ category, sort }],queryFn: fetchProducts,placeholderData: keepPreviousData,});if(error)return<prole="alert">Failed to load products:{error.message}</p>;return(<div><selectvalue={category}onChange={(e)=>setCategory(e.target.value)}><optionvalue="all">All</option><optionvalue="electronics">Electronics</option><optionvalue="clothing">Clothing</option></select><selectvalue={sort}onChange={(e)=>setSort(e.target.value)}><optionvalue="newest">Newest</option><optionvalue="price-asc">Price: Low to High</option></select>{isLoading ?(<p>Loading...</p>):(<ulstyle={{opacity: isPlaceholderData || isFetching ?0.5:1}}>{data?.map((p)=>(<likey={p.id}>{p.name}</li>))}</ul>)}</div>);}"use client";importuseSWRfrom"swr";import{ useState }from"react";constfetcher=async(...args)=>{const res =awaitfetch(...args);if(!res.ok){const error =newError("API request failed");error.status= res.status;error.info=await res.json().catch(()=>null);throw error;}return res.json();};exportdefaultfunctionProductCatalog(){const[category, setCategory]=useState("all");const[sort, setSort]=useState("newest");const swrKey =`/api/products?${newURLSearchParams({ category, sort })}`;const{ data, isLoading, isValidating, error }=useSWR(swrKey, fetcher,{keepPreviousData:true,});if(error)return<prole="alert">Failed to load products:{error.message}</p>;return(<div><selectvalue={category}onChange={(e)=>setCategory(e.target.value)}><optionvalue="all">All</option><optionvalue="electronics">Electronics</option><optionvalue="clothing">Clothing</option></select><selectvalue={sort}onChange={(e)=>setSort(e.target.value)}><optionvalue="newest">Newest</option><optionvalue="price-asc">Price: Low to High</option></select>{isLoading ?(<p>Loading...</p>):(<ulstyle={{opacity: isValidating ?0.5:1}}>{data?.map((p)=>(<likey={p.id}>{p.name}</li>))}</ul>)}</div>);}TanStack Query’s structured array keys and built-in select option make complex filter combinations easier to manage at scale, especially when multiple components consume the same data with different transformations. SWR’s approach is leaner for simple cases where a serialized URL string is sufficient as the cache key.
Pattern 2: Cache Invalidation After Mutation
Scenario: A user submits a review; the reviews list should refresh.
"use client";import{ useQuery, useMutation, useQueryClient }from"@tanstack/react-query";exportdefaultfunctionReviewSection({ productId }){const queryClient =useQueryClient();const{data: reviews, error }=useQuery({queryKey:["reviews", productId],queryFn:()=>{const params =newURLSearchParams({productId:String(productId)});returnfetch(`/api/reviews?${params}`).then((r)=>{if(!r.ok)thrownewError("Failed to fetch reviews");return r.json();});},});const mutation =useMutation({mutationFn:async(newReview)=>{const res =awaitfetch("/api/reviews",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ productId,...newReview }),});if(!res.ok)thrownewError(await res.text());return res.json();},onSuccess:()=>{queryClient.invalidateQueries({queryKey:["reviews", productId]});},});if(error)return<prole="alert">Failed to load reviews:{error.message}</p>;return(<div><ul>{reviews?.map((r)=>(<likey={r.id}>{r.text}</li>))}</ul><buttononClick={()=> mutation.mutate({text:"Great product!",rating:5})}disabled={mutation.isPending}>{mutation.isPending?"Submitting...":"Submit Review"}</button>{mutation.isError&&<prole="alert"style={{color:"red"}}>{mutation.error.message}</p>}</div>);}"use client";importuseSWR,{ useSWRConfig }from"swr";import{ useState }from"react";constfetcher=async(url)=>{const res =awaitfetch(url);if(!res.ok){const error =newError("API request failed");error.status= res.status;error.info=await res.json().catch(()=>null);throw error;}return res.json();};exportdefaultfunctionReviewSection({ productId }){const{ mutate }=useSWRConfig();const reviewKey =`/api/reviews?${newURLSearchParams({productId:String(productId)})}`;const{data: reviews,error: fetchError }=useSWR(reviewKey, fetcher);const[isPending, setIsPending]=useState(false);const[submitError, setSubmitError]=useState(null);asyncfunctionsubmitReview(){if(isPending)return;setIsPending(true);setSubmitError(null);try{const res =awaitfetch("/api/reviews",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ productId,text:"Great product!",rating:5}),});if(!res.ok)thrownewError(`Submit failed:${res.status}`);awaitmutate(reviewKey);}catch(err){setSubmitError(err.message);}finally{setIsPending(false);}}if(fetchError)return<prole="alert">Failed to load reviews:{fetchError.message}</p>;return(<div><ul>{reviews?.map((r)=>(<likey={r.id}>{r.text}</li>))}</ul><buttononClick={submitReview}disabled={isPending}>{isPending ?"Submitting...":"Submit Review"}</button>{submitError &&<prole="alert"style={{color:"red"}}>{submitError}</p>}</div>);}TanStack Query’s mutation lifecycle hooks (onSuccess, onError, isPending) reduce boilerplate for forms that need loading, error, and success states. SWR keeps things closer to raw fetch, which is straightforward but requires manual state management for pending and error states, as shown with the useState guard above.
TanStack Query’s mutation lifecycle hooks (
onSuccess,onError,isPending) reduce boilerplate for forms that need loading, error, and success states.
Pattern 3: Optimistic Updates with Rollback
Scenario: A user toggles a “favorite” heart icon; the UI updates instantly and rolls back on failure.
"use client";import{ useQuery, useMutation, useQueryClient }from"@tanstack/react-query";exportdefaultfunctionFavoriteButton({ productId }){const queryClient =useQueryClient();const{data: product }=useQuery({queryKey:["product", productId],queryFn:()=>fetch(`/api/products/${productId}`).then((r)=>{if(!r.ok)thrownewError("Failed to fetch product");return r.json();}),});const toggleFav =useMutation({mutationFn:async()=>{const res =awaitfetch(`/api/products/${productId}/favorite`,{method:"POST"});if(!res.ok)thrownewError(await res.text());return res.json();},onMutate:async()=>{await queryClient.cancelQueries({queryKey:["product", productId]});const previous = queryClient.getQueryData(["product", productId]);queryClient.setQueryData(["product", productId],(old)=>{if(!old)return old;return{...old,isFavorited:!old.isFavorited};});return{ previous };},onError:(_err, _vars, context)=>{queryClient.setQueryData(["product", productId], context.previous);},onSettled:()=>{queryClient.invalidateQueries({queryKey:["product", productId]});},});return(<buttononClick={()=> toggleFav.mutate()}>{product?.isFavorited ?"♥":"♡"}</button>);}"use client";importuseSWRfrom"swr";constfetcher=async(url)=>{const res =awaitfetch(url);if(!res.ok){const error =newError("API request failed");error.status= res.status;error.info=await res.json().catch(()=>null);throw error;}return res.json();};exportdefaultfunctionFavoriteButton({ productId }){const{data: product, mutate }=useSWR(`/api/products/${productId}`,fetcher);asyncfunctiontoggleFav(){awaitmutate(async(current)=>{const res =awaitfetch(`/api/products/${productId}/favorite`,{method:"POST"});if(!res.ok)thrownewError("Favorite toggle failed");return{...current,isFavorited:!current.isFavorited};},{optimisticData:(current)=>({...current,isFavorited:!current.isFavorited,}),rollbackOnError:true,revalidate:true,});}return(<buttononClick={toggleFav}>{product?.isFavorited ?"♥":"♡"}</button>);}Both libraries support optimistic UI, but TanStack Query’s explicit onMutate to onError to onSettled lifecycle gives finer control for complex rollback scenarios, such as when multiple queries need restoration. SWR’s declarative rollbackOnError combined with optimisticData is more concise for straightforward toggles. Note that rollbackOnError only activates when the mutator function throws an error. The if (!res.ok) throw check above is essential, because fetch resolves successfully even on HTTP 4xx/5xx responses.
Setting Up Providers in the App Router
TanStack Query Provider
"use client";import{QueryClient,QueryClientProvider}from"@tanstack/react-query";import{ReactQueryDevtools}from"@tanstack/react-query-devtools";import{ useState }from"react";constDEFAULT_STALE_MS=60_000;exportdefaultfunctionQueryProvider({ children }){const[queryClient]=useState(()=>newQueryClient({defaultOptions:{queries:{staleTime:DEFAULT_STALE_MS,},},}));return(<QueryClientProviderclient={queryClient}>{children}<ReactQueryDevtoolsinitialIsOpen={false}/></QueryClientProvider>);}}Import this component into the root layout and wrap {children} with it. The useState initializer ensures a single QueryClient instance per client-side render, avoiding shared state across requests during SSR. Setting a staleTime default prevents the client from immediately refetching data that was already prefetched on the server, which would otherwise cause double data fetching. The devtools package renders nothing unless NODE_ENV is development, so the component can stay in the tree for production builds.
importQueryProviderfrom"@/components/QueryProvider";exportdefaultfunctionRootLayout({ children }){return(<html><body><QueryProvider>{children}</QueryProvider></body></html>);}SWR Provider (Optional)
"use client";import{SWRConfig}from"swr";constfetcher=async(url)=>{const res =awaitfetch(url);if(!res.ok){const error =newError("API request failed");error.status= res.status;error.info=await res.json().catch(()=>null);throw error;}return res.json();};exportdefaultfunctionSWRProvider({ children }){return(<SWRConfigvalue={{ fetcher,revalidateOnFocus:true}}>{children}</SWRConfig>);}SWR works without a provider. The SWRConfig wrapper is optional and exists primarily to set global defaults like a shared fetcher function and revalidation behavior, reducing repetition across components. Without a provider, all components share SWR’s default global cache, and you can call the global mutate exported from swr anywhere. Add an SWRConfig ancestor only when you want a different cache provider or scoped defaults.
Performance, DX, and Ecosystem Considerations
Bundle Impact
In a quick esbuild measurement (minified, gzipped, React excluded), SWR 2.5 came to about 5.5 kB and TanStack Query 5.104 with QueryClient, useQuery and useMutation to about 7 kB. Measure your own build, since the result depends on which hooks you import. The gap is too small to decide the choice on its own. Both libraries support tree-shaking; effectiveness depends on bundler configuration, and named imports (import { useQuery }) enable better tree-shaking than namespace imports.
DevTools
TanStack Query ships official devtools with query state inspection, cache timeline visualization, and manual invalidation controls. SWR’s devtools are a community project (swr-devtools) and less feature-rich. For teams debugging complex cache interactions across many query keys, TanStack Query’s devtools provide a significant productivity advantage.
TypeScript Experience
Both libraries ship full type definitions. TanStack Query’s generics on useQuery<TData, TError> and useMutation provide stricter type contracts, especially for mutation lifecycle hooks where the context type flows from onMutate through onError. SWR’s typing works well for straightforward cases but offers less granularity in mutation flows.
Decision Framework: How to Choose
Choose SWR when data-fetching needs are straightforward: read-heavy pages with simple cache keys, minimal or no mutations, and a priority on minimal bundle size. SWR requires no provider to get started, so initial setup is a single hook call.
When your application requires structured mutations with optimistic rollback, multi-dimensional query keys, garbage-collection timing, or query cancellation, TanStack Query is the better fit. Its first-party devtools with query-state inspection and cache timeline views also help teams debug complex cache interactions faster.
Consider neither for page-initial data that Server Components can fetch directly. The App Router makes this the default path, and adding a client-side library for data that does not depend on user interaction adds unnecessary complexity and bundle weight.
The App Router makes this the default path, and adding a client-side library for data that does not depend on user interaction adds unnecessary complexity and bundle weight.
The App Router Narrows the Surface
The App Router reduces the surface area where either library is needed. Server Components handle the data-fetching work that previously required client-side libraries by default. For the interaction-time scenarios that remain, TanStack Query and SWR each cover the ground well, with different trade-off profiles: TanStack Query offers more structure for mutations and cache control; SWR offers less overhead for read-heavy, simple-key use cases. The decision table and paired code examples above are designed as a durable reference.
Sharing our passion for building incredible internet things.


