use-q

Command Palette

Search for a command to run...

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:

  1. Pre-fetch on the server with a plain fetcher. No React, no QueryClient, no schema — createFetcher takes a base URL and works with raw paths.
  2. Inject into the client cache using one of three techniques: setQueryData, HydrationBoundary, or initialData.

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

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

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>.path instead of duplicating path strings.
  • 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.