SSR & loaders
Use the raw fetcher in React Router, TanStack Router, Next.js server components, and server actions — and hydrate into the client cache.
use-q's framework-agnostic core (createFetcher) shines on the server. You can pre-fetch data anywhere — route loaders, RSC, server actions, edge workers — and seamlessly hand it to the client cache.
The ideas are React Query meets React Router and Seeding the Query Cache: fetch once on the server, put the result in the same query key the client hook will read, and skip the loading flash. For TanStack Router's first-class Query integration specifically, see TkDodo's TanStack Router and Query — this guide stays on the shared-cache pattern.
The pattern, in one picture
┌──── server ────┐ ┌──── client ────┐
│ createFetcher │ → response → │ setQueryData / │
│ .fetch(…) │ │ HydrationBound│
└────────────────┘ │ /initialData │
└────────────────┘Two things are happening:
- Pre-fetch on the server with a plain fetcher. No React, no
QueryClient, no schema —createFetchertakes a base URL and works with raw paths. - Inject into the client cache using one of three techniques:
setQueryData,HydrationBoundary, orinitialData.
The fetcher's low-level API is:
fetcher.fetch<TResponse>(path, {
method, // defaults to "GET"
params, // fills {placeholders} in the path
searchParams, // appended as ?key=value
body, // JSON-stringified for POST/PUT/PATCH/DELETE
signal,
headers,
});React Router v6 data loader
// src/api/server.ts
import { createFetcher } from "@use-q/api-client";
export const serverFetcher = createFetcher({
baseUrl: import.meta.env.VITE_API_BASE_URL,
});// src/routes/posts.ts
import type { LoaderFunctionArgs } from "react-router-dom";
import { serverFetcher } from "@/api/server";
import type { Post } from "@/api/schema";
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return serverFetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: params.facilityId! },
signal: request.signal,
});
}// src/routes/PostsPage.tsx
import { useLoaderData } from "react-router-dom";
import { useQ } from "@/api/client";
export function PostsPage() {
const loaderPosts = useLoaderData() as Awaited<
ReturnType<typeof postsLoader>
>;
const facilityId = "f1"; // ← from route params
const { data } = useQ(
"listPosts",
{ params: { facilityId } },
{ initialData: () => loaderPosts },
);
return data?.map((p) => <article key={p.id}>{p.title}</article>);
}initialData hydrates the cache without making an extra network round-trip. The component renders synchronously on the first paint.
Make sure the loader-fetched input matches the useQ input. If the cache key differs (e.g. the loader fetched without searchParams, but the hook calls with { search: "" }), TanStack Query sees a different key and fires a new fetch.
TanStack Router loader
// routeTree.gen.ts (concept)
import { createFileRoute } from "@tanstack/react-router";
import { serverFetcher } from "@/api/server";
import { api } from "@/api/client";
export const Route = createFileRoute("/posts/$facilityId")({
loader: async ({ params, abortController }) => {
const data = await serverFetcher.fetch<Post[]>(
"/facilities/{facilityId}/posts",
{
params: { facilityId: params.facilityId },
signal: abortController.signal,
},
);
// Hydrate directly into the client cache
api.queryClient.setQueryData(
api.queryKeys.listPosts({
params: { facilityId: params.facilityId },
}),
data,
);
return data;
},
});setQueryData is the imperative cousin of initialData — same effect, just at a different point in the lifecycle. api.queryKeys.listPosts(input) builds exactly the key useQ("listPosts", input) reads from, so the hydrated entry is picked up with no extra fetch.
Next.js server components
In RSC, the recommended pattern is HydrationBoundary + dehydrate:
// app/posts/[facilityId]/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { api } from "@/api/client";
import { PostsClient } from "./PostsClient";
export default async function Page({
params: { facilityId },
}: {
params: { facilityId: string };
}) {
await api.queryClient.prefetchQuery({
queryKey: api.queryKeys.listPosts({ params: { facilityId } }),
queryFn: ({ signal }) =>
api.fetcher.fetch(api.schema.listPosts.path, {
params: { facilityId },
signal,
}),
});
return (
<HydrationBoundary state={dehydrate(api.queryClient)}>
<PostsClient facilityId={facilityId} />
</HydrationBoundary>
);
}// app/posts/[facilityId]/PostsClient.tsx
"use client";
import { useQ } from "@/api/client";
export function PostsClient({ facilityId }: { facilityId: string }) {
const { data } = useQ("listPosts", { params: { facilityId } });
return data?.map((p) => <article key={p.id}>{p.title}</article>);
}The client component picks up the dehydrated state from <HydrationBoundary> and renders synchronously — no flash of loading. The key detail: api.queryKeys.listPosts(...) in the prefetch and useQ("listPosts", ...) in the client component build the same key, so hydration matches.
Per-request fetcher
Server components must isolate per-request state. Build a fresh fetcher per render so cookies and auth headers don't leak between requests:
// app/lib/server-fetcher.ts
import { createFetcher } from "@use-q/api-client";
import { cookies } from "next/headers";
export function getServerFetcher() {
return createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: () => ({
Authorization: `Bearer ${cookies().get("token")?.value ?? ""}`,
}),
});
}Call getServerFetcher() at the top of each server component. Don't capture it in a module-level constant — that's the leakage trap.
Server actions
"use server";
import { revalidatePath } from "next/cache";
import { getServerFetcher } from "@/app/lib/server-fetcher";
export async function createPostAction(facilityId: string, formData: FormData) {
const post = await getServerFetcher().fetch<Post>(
"/facilities/{facilityId}/posts",
{
method: "POST",
params: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
},
);
revalidatePath(`/posts/${facilityId}`);
return post;
}Edge workers
createFetcher runs on any runtime with fetch. In a Cloudflare Worker:
import { createFetcher } from "@use-q/api-client";
export default {
async fetch(req: Request, env: Env) {
const fetcher = createFetcher({
baseUrl: env.API_BASE_URL,
headers: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch<Post[]>("/facilities/{facilityId}/posts", {
params: { facilityId: new URL(req.url).searchParams.get("f")! },
});
return Response.json(posts);
},
} satisfies ExportedHandler<Env>;(If the runtime requires a bound fetch implementation, pass it explicitly via the fetch option: createFetcher({ baseUrl, fetch: myFetch }).)
When to use which hydration technique
| Technique | When | Pros | Cons |
|---|---|---|---|
initialData (per hook) | One-off pages, React Router / Vite SPAs | Simple; explicit; no dependency on <HydrationBoundary> | Have to pass params through to the component |
setQueryData (in loader / action) | Imperative wiring, mixed-source loaders | Total control | Easy to mismatch the key |
dehydrate + HydrationBoundary | Next.js app router, multiple queries per page | One declaration covers an entire subtree | Requires a QueryClient on the server |
Tips
- Construct fetchers per request (or per worker invocation) on the server to avoid leaking auth state between users.
- Match query keys exactly. Use
api.queryKeys.<route>(input)to build keys both server-side and client-side — same function, same shape. - Reuse the schema for paths. The server fetcher is schema-free, but route paths live in your schema module — reference
schema.<route>.pathinstead of duplicating path strings. - Don't ship the React layer to the server bundle.
@use-q/api-clienthas zero React dependency. Keep server-only code in a file that doesn't transitively import@use-q/api-client-react.