TanStack Query Patterns for Next.js
Stale-while-revalidate, optimistic mutations, cache invalidation, server-side prefetching, and shared query key conventions.
TanStack Query (formerly React Query) solves async state in a way that nothing else does. After using it heavily in NodWatch, here are the patterns that actually matter in a Next.js App Router context.
Query Key Conventions
Query keys are the most important design decision. Use arrays with a consistent hierarchy:
// lib/query-keys.ts
export const queryKeys = {
watchlist: {
all: ["watchlist"] as const,
byUser: (userId: string) => ["watchlist", userId] as const,
},
progress: {
all: ["progress"] as const,
byTitle: (tmdbId: number, mediaType: string) =>
["progress", tmdbId, mediaType] as const,
},
recommendations: (userId: string) => ["recommendations", userId] as const,
};Structured keys let you invalidate at any level — invalidate ["watchlist"] to bust all watchlist queries, or ["watchlist", userId] for a specific user.
Stale-While-Revalidate in Practice
The default staleTime: 0 means every mount triggers a refetch. For data that doesn't change often, raise it:
export function useRecommendations(userId: string) {
return useQuery({
queryKey: queryKeys.recommendations(userId),
queryFn: () => fetch("/api/recommendations").then((r) => r.json()),
staleTime: 1000 * 60 * 10, // 10 minutes — recommendations are expensive to compute
gcTime: 1000 * 60 * 30, // keep in memory for 30 minutes
});
}For real-time data like progress:
export function useContinueWatching() {
return useQuery({
queryKey: ["continue-watching"],
queryFn: () => fetch("/api/user/continue-watching").then((r) => r.json()),
staleTime: 0,
refetchOnWindowFocus: true,
});
}Optimistic Mutations
Show the update immediately, roll back if the server fails:
export function useToggleWatchlist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tmdbId, inList }: { tmdbId: number; inList: boolean }) =>
fetch("/api/watchlist", {
method: inList ? "DELETE" : "POST",
body: JSON.stringify({ tmdbId }),
}),
onMutate: async ({ tmdbId, inList }) => {
await queryClient.cancelQueries({ queryKey: ["watchlist"] });
const previous = queryClient.getQueryData(["watchlist"]);
queryClient.setQueryData(["watchlist"], (old: any[]) =>
inList ? old.filter((item) => item.tmdbId !== tmdbId) : [...old, { tmdbId }]
);
return { previous };
},
onError: (_, __, context) => {
queryClient.setQueryData(["watchlist"], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["watchlist"] });
},
});
}The three-phase pattern: onMutate → optimistic update, onError → rollback, onSettled → sync with server.
Server-Side Prefetching with App Router
Prefetch in server components and dehydrate to avoid waterfall fetches on the client:
// app/dashboard/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function DashboardPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["watchlist"],
queryFn: () => db.select().from(watchlist).where(eq(watchlist.userId, userId)),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<WatchlistGrid />
</HydrationBoundary>
);
}On the client, useQuery(["watchlist"]) finds the prefetched data immediately — no loading state on first render.
Dependent Queries
Chain queries where one depends on another:
export function useEpisodeProgress(tmdbId: number, season: number) {
const { data: show } = useShow(tmdbId);
return useQuery({
queryKey: ["progress", tmdbId, "season", season],
queryFn: () => fetchSeasonProgress(tmdbId, season),
enabled: !!show, // only run when show data is available
});
}Provider Setup for Next.js
// components/providers/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute default
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}Mount it in app/layout.tsx. Note: QueryClientProvider must be a client component, but you can wrap it and use it from a server layout.
The combination of server prefetching + client-side optimistic updates + structured query keys gives you a data layer that's both fast and maintainable.