Type‑Safe Reactive Query Caching in Next.js: Harnessing TanStack Query, Zod, and ISR for Rock‑Solid Data Sync
Introduction
Modern Next.js applications often blend static rendering with client‑side data fetching. The result is a fast first paint, but keeping that data fresh can become a tangled web of stale caches, mismatched types, and hard‑to‑debug API changes.
This article shows a practical recipe for building a type‑safe, reactive cache that:
- Validates every payload with Zod at compile‑time and runtime.
- Shares the same cache between Server‑Side Rendering (SSR), Incremental Static Regeneration (ISR), and client components via TanStack Query.
- Invalidates automatically when underlying data changes, using Next.js revalidation hooks and TanStack’s query invalidation API.
You’ll walk away with a minimal, production‑ready codebase that you can drop into any Next.js 14+ app.
Who should read this?
Front‑end engineers comfortable with TypeScript, React Query (aka TanStack Query), and the basics of Next.js data‑fetching strategies.
1. The problem space
| Scenario | What usually goes wrong? |
|---|---|
| ISR page shows a list of products | After a new product is added via an admin API, the static page still shows the old list until the next revalidation interval. |
| Client component fetches the same endpoint | The client uses fetch directly, bypassing the server‑side cache, leading to duplicate network traffic. |
| API contract changes | A new field is added, but some components still assume the old shape, causing runtime crashes that TypeScript can’t catch because the data is any. |
The core issue is lack of a single source of truth for data shape and cache state. The solution is to centralise fetching, validation, and caching in a place that both the server (ISR) and the client can consume.
2. Tooling at a glance
| Tool | Why we need it |
|---|---|
| TanStack Query (v5) | Global, type‑aware cache with automatic refetching, pagination, and optimistic updates. |
| Zod | Compile‑time inference + runtime validation, guaranteeing that the data stored in the cache matches the expected schema. |
Next.js ISR (revalidateTag) |
Allows server‑side pages to be regenerated on demand when a tag is invalidated. |
| React Server Components (RSC) | Enable us to run the same query on the server without sending extra JavaScript to the client. |
3. Defining a shared schema with Zod
Create a single source of truth for the API payload. Put it in src/schemas/product.ts.
// src/schemas/product.ts
import { z } from 'zod';
export const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string(),
priceCents: z.number().int(),
inStock: z.boolean(),
// optional fields can be added later without breaking older code
description: z.string().optional(),
});
export type Product = z.infer<typeof ProductSchema>;
export type ProductList = Product[];
Tip: Export the zod schema and the inferred TypeScript type. The type is used by TanStack Query, while the schema is used at runtime to validate the raw JSON.4. A typed fetch wrapper
Wrap fetch so that every response is validated automatically.
// src/lib/fetchTyped.ts
import { z } from 'zod';
export async function fetchTyped<T>(
input: RequestInfo,
init: RequestInit,
schema: z.ZodSchema<T>,
): Promise<T> {
const res = await fetch(input, init);
if (!res.ok) {
throw new Error(`Network error ${res.status}`);
}
const json = await res.json();
return schema.parse(json); // throws if shape mismatches
}
Now any call to the API is both type‑checked at compile time (via generic T) and runtime‑checked (via schema.parse).
5. TanStack Query client that lives on both server and client
Next.js can expose a singleton query client that works in Node (for ISR) and in the browser.
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { ProductSchema, ProductList } from '@/schemas/product';
import { fetchTyped } from '@/lib/fetchTyped';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 5‑minute stale time, but we’ll invalidate via tags
staleTime: 5 * 60 * 1000,
// Retry only on network errors
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('Network')) {
return failureCount < 3;
}
return false;
},
},
},
});
// Helper: fetch product list and store in the cache
export async function prefetchProducts() {
await queryClient.prefetchQuery(['products'], async () => {
return fetchTyped('/api/products', {}, ProductSchema.array());
});
}
// Export a React component to hydrate the cache on the client
export function QueryHydration({ children }: { children: React.ReactNode }) {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}
Key points
- The query key
['products']is stable across server and client. prefetchQueryruns on the server (ISR) and stores the result in the same client instance that will later be serialized.HydrationBoundaryensures the client receives the exact same cache without an extra request.
6. ISR page that uses the shared cache
// src/app/products/page.tsx
import { queryClient, prefetchProducts, QueryHydration } from '@/lib/queryClient';
import { ProductList } from '@/schemas/product';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
export const revalidate = 0; // we’ll manually trigger via tags
export default async function ProductsPage() {
// 1️⃣ Run the prefetch on the server
await prefetchProducts();
// 2️⃣ Return a component that hydrates the cache on the client
return (
<QueryHydration>
<ProductsList />
</QueryHydration>
);
}
// Client component that reads from the cache
function ProductsList() {
const { data, isLoading, isError } = useQuery<ProductList>(['products']);
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Failed to load products.</p>;
return (
<ul>
{data!.map((p) => (
<li key={p.id}>
<Link href={`/products/${p.id}`}>{p.name}</Link> – ${(p.priceCents / 100).toFixed(2)}
</li>
))}
</ul>
);
}
What happens?
- When the page is first built,
prefetchProductsruns, hits the API, validates the payload, and stores it inqueryClient. - The
HydrationBoundaryserialises the cache into the HTML. - The client rehydrates the cache instantly—no additional request.
7. Triggering ISR regeneration with revalidateTag
When an admin creates, updates, or deletes a product, we want the static page to refresh immediately. Next.js 14 introduced revalidateTag, which works nicely with TanStack Query’s invalidation.
// src/app/api/admin/products/route.ts
import { NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
import { queryClient } from '@/lib/queryClient';
import { ProductSchema } from '@/schemas/product';
import { fetchTyped } from '@/lib/fetchTyped';
// Example: POST /api/admin/products
export async function POST(request: Request) {
const body = await request.json();
const newProduct = ProductSchema.parse(body); // runtime validation
// Call downstream service / DB (omitted)
// await db.insert(newProduct);
// 1️⃣ Invalidate TanStack Query cache
queryClient.invalidateQueries(['products']);
// 2️⃣ Invalidate ISR tag that the page uses
await revalidateTag('products-page');
return NextResponse.json({ success: true });
}
Now the ISR page can be written to listen to that tag:
// src/app/products/page.tsx (add at top)
export const dynamic = 'force-dynamic'; // ensures tag handling
export const tags = ['products-page'];
When the admin endpoint runs, the tag is cleared; Next.js automatically rebuilds the static page on the next request, and the client cache is already invalidated, causing an immediate refetch.
8. Optimistic updates for a snappy UI
For mutations that the user initiates (e.g., “Add to cart”), you can keep the UI responsive while still guaranteeing type safety.
// src/hooks/useAddToCart.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { fetchTyped } from '@/lib/fetchTyped';
const CartItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1),
});
export function useAddToCart() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (item: z.infer<typeof CartItemSchema>) => {
return fetchTyped('/api/cart', { method: 'POST', body: JSON.stringify(item) }, z.object({ success: z.boolean() }));
},
// Optimistic update
onMutate: async (newItem) => {
await qc.cancelQueries(['cart']);
const previous = qc.getQueryData(['cart']);
qc.setQueryData(['cart'], (old: any) => ({
...old,
items: [...(old?.items ?? []), { ...newItem, id: 'temp-' + Date.now() }],
}));
return { previous };
},
onError: (err, newItem, context) => {
qc.setQueryData(['cart'], context?.previous);
},
onSettled: () => {
qc.invalidateQueries(['cart']);
},
});
}
Why is this safe?
- The mutation function validates the server response.
- The optimistic update works on a typed copy of the data (
CartItemSchema). - If the server rejects the request, we roll back to the previous cache state.
9. Testing the contract – unit + integration
Because the schema lives in a single file, you can reuse it in API route tests.
// tests/api/products.test.ts
import { expect, test } from 'vitest';
import { ProductSchema } from '@/schemas/product';
import { fetchTyped } from '@/lib/fetchTyped';
test('GET /api/products returns a valid list', async () => {
const data = await fetchTyped('/api/products', {}, ProductSchema.array());
expect(data).toBeArray();
expect(ProductSchema.safeParse(data[0]).success).toBe(true);
});
If the backend changes, the test fails before any UI component breaks, giving you a fast feedback loop.
10. Putting it all together – folder layout
src/
├─ app/
│ └─ products/
│ └─ page.tsx // ISR page
├─ api/
│ └─ admin/
│ └─ products/
│ └─ route.ts // mutation that invalidates tag & query
├─ lib/
│ ├─ fetchTyped.ts // typed fetch helper
│ └─ queryClient.ts // shared TanStack Query client
└─ schemas/
└─ product.ts // Zod schema + TS types
A clean separation makes the cache logic reusable across many pages (e.g., product details, search results) without duplication.
11. Common pitfalls & how to avoid them
| Pitfall | Solution |
|---|---|
| Cache mismatch after a schema change | Run npm run test that validates every endpoint against its Zod schema; CI will catch breaking changes. |
| Revalidation never fires | Ensure the page exports export const tags = ['your-tag'] and that the admin route calls await revalidateTag('your-tag'). |
| SSR and client use different query keys | Keep keys as constants (e.g., const PRODUCTS_KEY = ['products'] as const). |
| Large payloads cause hydration bloat | Use dehydrate(queryClient, { shouldDehydrateQuery: q => q.state.data?.length < 50 }) to limit what gets sent to the client. |
| Optimistic updates break type safety | Always derive the optimistic payload from a Zod schema, not from any. |
12. Performance considerations
- Stale‑while‑revalidate: Keep
staleTimeshort (minutes) and rely on tag‑based ISR for the authoritative source. - Selective dehydration: Dehydrate only the queries needed for the first paint; other data can be fetched lazily on the client.
- Parallel prefetch: If a page needs multiple resources,
await Promise.all([prefetchProducts(), prefetchCategories()])reduces server‑side latency.
13. Conclusion
By uniting TanStack Query, Zod, and Next.js ISR, you get:
- Single source of truth for data shape.
- Zero‑duplicate network requests between server and client.
- Instant UI updates with optimistic mutations that remain type‑safe.
- Automatic regeneration of static pages whenever the underlying data changes.
The pattern scales from a tiny blog to a large e‑commerce platform. The only thing you need to add is discipline: keep schemas close to the API, always validate at the edge, and let TanStack Query handle the rest.
Happy caching! 🚀
Member discussion