Queries
Suspense boundaries, server prefetching and timing presets for TanStack Query
@zeno-lib/query is a small layer over TanStack Query for
suspense queries in a server-rendered React app. It gives you one boundary that handles loading,
errors and retry, a server component that prefetches a route's data into the page, and a set of
timing presets so queries share a few staleTime values instead of each inventing its own. The
package is UI-free; the error card that goes with it comes from the registry, so its source lives
in your app and you can restyle it.
Installation
pnpm add @zeno-lib/query @tanstack/react-query
pnpm dlx shadcn@latest add zeno-lib/zeno/query-error-fallbackThe registry item adds QueryErrorFallback to components/query/ and installs shadcn's
Button. Your app still needs its own QueryClientProvider.
Suspense boundaries
Wrap every component that calls useSuspenseQuery in a QuerySuspense. It nests TanStack's
recommended QueryErrorResetBoundary, an error boundary and Suspense. fallback shows while
the query loads, and errorFallback shows when it fails, with an onRetry that resets the query.
"use client"
import { QuerySuspense } from "@zeno-lib/query/query-suspense"
import { QuerySuspenseLoading } from "@zeno-lib/query/query-suspense-loading"
import { useSuspenseQuery } from "@tanstack/react-query"
import { QueryErrorFallback } from "@/components/query/query-error-fallback"
import { Skeleton } from "@/components/ui/skeleton"
function Notes({ dealId }: { dealId: string }) {
const { data: notes } = useSuspenseQuery(notesQueryOptions(dealId))
return (
<ul>
{notes.map((note) => (
<li key={note.id}>{note.content}</li>
))}
</ul>
)
}
export function NotesCard({ dealId }: { dealId: string }) {
return (
<QuerySuspense
fallback={
<QuerySuspenseLoading className="flex flex-col gap-2 p-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</QuerySuspenseLoading>
}
errorFallback={(props) => (
<QueryErrorFallback {...props} fallbackMessage="Could not load notes." />
)}
>
<Notes dealId={dealId} />
</QuerySuspense>
)
}QuerySuspenseLoading is a plain div that sets aria-busy and aria-live for you, so screen
readers announce the skeleton without an extra wrapper.
Why the boundary waits for hydration
A Server Function cannot run during the initial server render. React throws "Server Functions
cannot be called during initial render" there. So a useSuspenseQuery whose queryFn calls one
would render its error into the HTML, while the browser suspends instead. The mismatch makes React
re-render the whole subtree on the client.
To keep both passes identical, QuerySuspense renders only its fallback on the server and
through hydration, then runs the query in the browser. That avoids the error, but the route
arrives as a skeleton. Prefetching lets the server render the real HTML.
Prefetching a route
QueryHydrationBoundary is an async server component. It hands a fresh QueryClient to your
prefetch, awaits it, and dehydrates the cache into the page:
// app/deals/page.tsx
import { QueryHydrationBoundary } from "@zeno-lib/query/query-hydration-boundary"
export default function DealsPage() {
return (
<QueryHydrationBoundary
prefetch={(queryClient) =>
Promise.all([
queryClient.fetchQuery(dealsQueryOptions(defaultFilters)),
queryClient.prefetchQuery(advisorsQueryOptions()),
])
}
>
<DealsBoard />
</QueryHydrationBoundary>
)
}Inside it, every QuerySuspense renders on the server too, and the queries resolve from the
cache in both passes. Three rules keep that working:
- Prefetch through the same
queryOptionsfactory the browser uses, unchanged. The cache entry needs the exact key and value the client would build, or the client refetches and the prefetch is wasted. Don't spread the options and swap thequeryFn. - Prefetch every suspense query the server pass reaches. The boundary lets every
QuerySuspensein its subtree render on the server, so a query you left out runs itsqueryFnduring the server render and fails there. A component the server does not render (an inactive tab, a closed dialog) doesn't count. - Use
fetchQueryfor the read the page cannot render without.prefetchQueryswallows failures, so a failed prefetch still sends the page, with an error card in the HTML that the browser then throws away.fetchQuerythrows instead, so the page render fails.
No prop can switch this on. PrefetchedQueries derives the flag QuerySuspense reads from the
dehydrated payload, so a prefetch that produced nothing keeps the client-only behaviour.
To build the server QueryClient yourself (for example with shared defaultOptions), pass
createQueryClient. QueryHydrationBoundary forwards dehydrateOptions to TanStack's
dehydrate.
If you already have a dehydrated state from elsewhere, render PrefetchedQueries from
@zeno-lib/query/prefetched-queries directly. It is TanStack's HydrationBoundary plus the flag.
Timing presets
Spread a preset as the first property of queryOptions instead of inventing a staleTime:
import { cachedQueryDefaults } from "@zeno-lib/query/query-defaults"
import { queryOptions } from "@tanstack/react-query"
export const countriesQueryOptions = () =>
queryOptions({
...cachedQueryDefaults,
queryFn: fetchCountries,
queryKey: ["countries"],
})| Export | staleTime | refetchInterval | Typical use |
|---|---|---|---|
liveBackgroundQueryDefaults | 15 s | 15 s, also in the background | Fast-moving data that keeps updating while the tab is hidden |
liveQueryDefaults | 30 s | 30 s | Fast-moving data refreshed while it is on screen |
shortLivedQueryDefaults | 1 min | none | Data that can be slightly stale |
longLivedQueryDefaults | 2 min | none | Slower-changing detail data |
cachedQueryDefaults | 5 min | none | Reference data; also keeps it cached for 30 min (gcTime) |
staticQueryDefaults | never stale | none | Effectively immutable data |
Screenshots of loading and error states
In development and tests, debugState="loading" or debugState="error" on a QuerySuspense
forces that UI without running the query, so you can capture it in a screenshot. QuerySuspense
ignores it when NODE_ENV is "production", but remove it from source once you're done.