Skip to content

SSR & loaders

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.

┌──── server ────┐ ┌──── client ────┐
│ createFetcher │ → response → │ setQueryData / │
│ .fetch(…) │ │ HydrationBound│
└────────────────┘ │ /initialData │
└────────────────┘

Two things are happening:

  1. Pre-fetch on the server with a fetcher built from the same schema. No React, no QueryClient.
  2. Inject into the client cache using one of three techniques: setQueryData, HydrationBoundary, or initialData.
src/api/server.ts
import { createFetcher } from "@use-q/api-client";
import { schema } from "./schema";
export const serverFetcher = createFetcher<typeof schema>(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
});
src/routes/posts.ts
import type { LoaderFunctionArgs } from "react-router-dom";
import { serverFetcher } from "@/api/server";
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return serverFetcher.fetch("listPosts", {
pathParams: { 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", {
pathParams: { 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.

// 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("listPosts", {
pathParams: { facilityId: params.facilityId },
signal: abortController.signal,
});
// Hydrate directly into the client cache
api.queryClient.setQueryData(
api.queryKeys.listPosts({
pathParams: { facilityId: params.facilityId },
}),
data,
);
return data;
},
});

setQueryData is the imperative cousin of initialData — same effect, just at a different point in the lifecycle.

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({ pathParams: { facilityId } }),
queryFn: ({ signal }) =>
api.fetcher.fetch("listPosts", {
pathParams: { 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", { pathParams: { 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.

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";
import { schema } from "@/api/schema";
export function getServerFetcher() {
return createFetcher<typeof schema>(schema, {
baseUrl: process.env.API_BASE_URL!,
defaultHeaders: () => ({
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.

"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("createPost", {
pathParams: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
});
revalidatePath(`/posts/${facilityId}`);
return post;
}

createFetcher runs on any runtime with fetch. In a Cloudflare Worker:

import { createFetcher } from "@use-q/api-client";
import { schema } from "./schema";
export default {
async fetch(req: Request, env: Env) {
const fetcher = createFetcher<typeof schema>(schema, {
baseUrl: env.API_BASE_URL,
fetchFn: fetch,
defaultHeaders: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch("listPosts", {
pathParams: { facilityId: new URL(req.url).searchParams.get("f")! },
});
return Response.json(posts);
},
} satisfies ExportedHandler<Env>;
TechniqueWhenProsCons
initialData (per hook)One-off pages, React Router / Vite SPAsSimple; explicit; no dependency on <HydrationBoundary>Have to pass params through to the component
setQueryData (in loader / action)Imperative wiring, mixed-source loadersTotal controlEasy to mismatch the key
dehydrate + HydrationBoundaryNext.js app router, multiple queries per pageOne declaration covers an entire subtreeRequires a QueryClient on the server
  • Reuse the schema, not the client. The server should construct its own fetcher per request (or per worker invocation) to avoid leaking auth state.
  • Match query keys exactly. Use api.queryKeys.<route>(params) to build keys both server-side and client-side — same function, same shape.
  • Don’t ship the React layer to the server bundle. @use-q/api-client has zero React dependency. Keep server-only code in a file that doesn’t transitively import @use-q/api-client-react.