Back to Blog
Dec 14, 20253 min readOnuzulike Anthony

TanStack Query Patterns for Next.js

Stale-while-revalidate, optimistic mutations, cache invalidation, server-side prefetching, and shared query key conventions.

Full-StackTanStack QueryNext.jsReact

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:

ts
// 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:

ts
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:

ts
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:

ts
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:

tsx
// 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:

ts
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

tsx
// 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.