Apr 24, 2026
TanStack Query with Next.js App Router: Server State Without useEffect
Learn how to use TanStack Query with the Next.js App Router for server state, prefetching, hydration, mutations, and cleaner data flows without fragile useEffect fetching.
8 min read
TanStack Query with Next.js App Router: Server State Without useEffect
If you are searching for TanStack Query Next.js App Router guidance, the real question is usually bigger than one library choice. It is about how to handle server state without scattering useEffect, loading flags, retry logic, and stale data rules across every client component. The App Router already pushes teams toward server-first rendering, so the best setup is one where your data layer matches that model instead of fighting it.
This guide explains where React Query Next.js App Router patterns still make sense, how to prefetch and hydrate data safely, and how to think about server state without useEffect in a production codebase. If you are still deciding what belongs on the server versus the client, read Next.js Server Components Patterns for Faster App Router Apps first. If your app already has caching questions, Next.js Caching and Revalidation Guide for App Router Apps is the right companion. And if client-side persistence has already caused rendering drift, React Hydration Mismatch in Next.js: Causes, Fixes, and Prevention shows the failure mode you want to avoid.
1. Server state is not the same as UI state
One reason teams struggle with data fetching is that they treat all state as one category. It is not.
UI state is things like:
- modal open or closed
- active tab
- selected row ids
- local form draft
Server state is different:
- it lives on the server, not in the component tree
- it can become stale
- it may need background refetching
- multiple screens may depend on the same record
- failure and retry rules matter
That is why libraries like TanStack Query exist. They are not a replacement for all state management. They are specifically good at coordinating server state. If you are still comparing client-state tools, React Context vs Zustand: Which State Management Pattern Fits Your App? is a better fit for that decision.
2. Why useEffect fetching breaks down
The classic pattern looks harmless at first:
"use client";
import { useEffect, useState } from "react";
type Project = {
id: string;
name: string;
};
export function ProjectsList() {
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const response = await fetch("/api/projects");
if (!response.ok) throw new Error("Failed to load projects");
const data = await response.json();
if (!cancelled) setProjects(data.projects);
} catch (err) {
if (!cancelled) setError("Could not load projects");
} finally {
if (!cancelled) setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>{error}</p>;
return <ul>{projects.map((project) => <li key={project.id}>{project.name}</li>)}</ul>;
}
The problem is not that this code is impossible to maintain. The problem is that every list, detail view, dashboard card, and mutation starts re-implementing the same concerns:
- request lifecycle
- retry behavior
- stale vs fresh data
- refetch after mutation
- deduping the same request across components
- loading and error state coordination
That is exactly the point where server state without useEffect becomes a better mental model. The component should describe what data it needs. The data layer should manage freshness and synchronization.
3. Where TanStack Query fits in an App Router app
In the App Router, the default should still be server-first rendering. If a page can fetch and render entirely in a Server Component, do that. TanStack Query is most useful when you have client-side interactivity around remote data:
- dashboards with filters and pagination
- search results that refetch based on user input
- optimistic mutations
- user-specific panels that refresh in the background
- reusable widgets mounted across multiple client routes
The mistake is using TanStack Query everywhere because it feels modern. A marketing page or a static content route usually does not need it. A filter-heavy client dashboard often does.
4. Set up the provider once
Keep the Query Client close to the client boundary instead of wrapping random sections ad hoc.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function AppQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
Use one stable client per browser session. Creating it inside render without useState or another stable holder can reset cache unexpectedly.
5. Prefetch on the server, hydrate on the client
This is the most useful TanStack Query Next.js App Router pattern because it avoids the blank-first-render problem. Fetch on the server when possible, then hydrate the cache for the client island.
// app/dashboard/page.tsx
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from "@tanstack/react-query";
import { DashboardClient } from "@/components/dashboard/DashboardClient";
import { getProjects } from "@/lib/projects";
export default async function DashboardPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["projects"],
queryFn: getProjects,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient />
</HydrationBoundary>
);
}
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchProjects() {
const response = await fetch("/api/projects");
if (!response.ok) throw new Error("Failed to fetch projects");
return response.json();
}
export function DashboardClient() {
const { data, isLoading, isError } = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
if (isLoading) return <p>Loading projects...</p>;
if (isError) return <p>Could not load projects.</p>;
return (
<ul>
{data.projects.map((project: { id: string; name: string }) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
);
}
The result is better than a client-only useEffect fetch:
- server rendering can prepare the first payload
- the client receives warm cache data
- later refetches remain declarative
That is the cleanest bridge between Server Components and interactive client widgets.
6. Choose query keys like API contracts
Query keys are not random labels. They define cache identity. If they are inconsistent, invalidation becomes fragile.
Good pattern:
useQuery({
queryKey: ["projects", teamId, "list", searchTerm],
queryFn: () => fetchProjects({ teamId, searchTerm }),
});
Weak pattern:
useQuery({
queryKey: ["data"],
queryFn: () => fetchProjects({ teamId, searchTerm }),
});
Include the variables that actually shape the result. That makes refetching and mutation invalidation predictable during code review.
7. Mutations should update cache intentionally
Many teams adopt TanStack Query for reads, then fall back to page refreshes after writes. That leaves a lot of value on the table.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function CreateProjectButton() {
const queryClient = useQueryClient();
const createProject = useMutation({
mutationFn: async (input: { name: string }) => {
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error("Failed to create project");
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
return (
<button onClick={() => createProject.mutate({ name: "New Project" })}>
Add project
</button>
);
}
For simple CRUD flows, invalidation is usually enough. For high-frequency UIs, optimistic updates may be worth the extra complexity. Use them selectively, not by default.
8. Avoid duplicating Next.js caching rules
One subtle architecture problem with React Query Next.js App Router is stacking too many caches without a policy. Next.js already gives you route-level and fetch-level caching controls. TanStack Query adds a client cache on top.
That is fine if each layer has a purpose:
- Next.js cache controls server rendering and revalidation
- TanStack Query controls client freshness after hydration
It gets messy when both layers are configured blindly. For example, if the server fetch is aggressively cached but the client query expects near-real-time freshness, your UI contract becomes confusing. Decide which layer owns freshness for each screen. That is the same discipline described in Next.js Caching and Revalidation Guide for App Router Apps.
9. Keep server-only concerns out of the client query layer
Do not move secrets, direct database calls, or privileged authorization logic into query functions just because TanStack Query makes data fetching convenient. Query functions running in the browser should call safe server endpoints or Server Actions backed by server-side validation.
If a route needs secure mutations, pair this pattern with Next.js Server Actions Security Best Practices for App Router Apps. If the biggest issue is not fetching but client boundary placement, revisit Next.js Server Components Patterns for Faster App Router Apps.
10. A practical decision rule
Use TanStack Query when all three are true:
- the data is remote and can go stale
- the UI is interactive on the client
- you need refetching, mutations, deduping, or cache-aware coordination
Skip it when:
- the route can stay fully server-rendered
- the data is static or request-only
- local component state is the real problem
That is the key distinction. TanStack Query is not a general state library. It is a server-state coordination layer. Once you treat it that way, the architecture gets much clearer.
Final takeaway
The best TanStack Query Next.js App Router setup is not "put every fetch in useQuery." It is server-first rendering plus a deliberate client cache only where interactivity requires it. That gives you cleaner loading flows, fewer duplicated useEffect fetches, and a more honest separation between UI state and server state.
If your current app mixes provider state, browser persistence, and fetch logic in the same client tree, untangle it one concern at a time. Start with the boundary. Then decide which data truly needs a client cache. That is how server state without useEffect becomes a maintainable pattern instead of another layer of abstraction.