tRPC + React Query: Type‑Safe Client Hooks Without Codegen
Use tRPC’s React Query integration to let the client infer input and output types from the server router. The guide shows a minimal setup, explains how batching and transformers work, and lists common pitfalls and limits.
14 Jul 2026, 20:38 UTC

Why Use tRPC’s React Query Integration?
When you export only the AppRouter type from the server and feed it into createTRPCReact, every hook you get (e.g., trpc.getUser.useQuery) is fully typed. No separate client generation step, no duplicated schemas, and instant compile‑time safety for both inputs and outputs.
Minimal Working Example
Server – Define and Export the Router
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => ({
id: input.id,
name: "Ada",
// Dates are serialized as strings unless a transformer is used
createdAt: new Date(),
})),
});
export type AppRouter = typeof appRouter; // <-- only the type is exported
Client – Create the typed TRPC client
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/router";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { QueryClient, QueryClientProvider } from "react-query";
export const trpc = createTRPCReact<AppRouter>();
const queryClient = new QueryClient();
function TRPCProvider({ children }: { children: React.ReactNode }) {
return (
<trpc.Provider
client={trpc.createClient({
links: [
httpBatchLink({
url: "/trpc",
// httpBatchLink groups queries into one GET request per tick
}),
],
transformer: superjson, // preserves Dates, Maps, etc.
})}
queryClient={queryClient}
>
{children}
</trpc.Provider>
);
}
Using a Hook in a Component
import { trpc } from "../trpc";
export function UserCard({ userId }: { userId: string }) {
const { data, error, isLoading } = trpc.getUser.useQuery({ id: userId });
if (isLoading) return <div>Loading…</div>;
if (error) return <div>Error: {error.message}</div>;
// TypeScript guarantees data has { id: string; name: string; createdAt: Date }
return (
<div>
<h3>{data.name}</h3>
<p>Member since {data.createdAt.toLocaleDateString()}</p>
</div>
);
}
How the Mechanism Works
1. Server side – The router is typed with zod validators. The AppRouter type captures every procedure’s input and output shape.
2. Client side – createTRPCReact<AppRouter>() reads that type and builds a set of hooks. Each hook’s argument type matches the procedure’s input validator, and the return type matches the query or mutation result.
3. Link & transformer – httpBatchLink sends multiple queries in a single HTTP GET. The transformer (e.g., superjson) serializes non‑JSON values so the client receives the original types.
4. React Query – The hooks are just wrappers around useQuery and useMutation, so all caching, refetching, and SSR features are available.
Limitations and Edge Cases
- JSON‑serializable only – Without a transformer,
Date,Map,Set,BigInt, and custom classes become plain strings or lose data. Usesuperjsonor a custom transformer on both server and client. - Batched GET URL length –
httpBatchLinkconcatenates query parameters into a single URL. If you fire dozens of queries in one tick, the URL may exceed browser limits (~2000 chars). Prefer fewer, larger procedures or switch tohttpLinkfor high‑volume scenarios. - SSR hydration – tRPC does not automatically hydrate React Query’s cache. Use
dehydrate/rehydratefromreact-queryornext-ssrpatterns. The client will still type‑check, but the data won’t be pre‑loaded. - Error serialization –
TRPCErrormessages are sent over the wire, but stack traces andcausefields are omitted. Log server‑side errors separately. - Version drift – Import paths (e.g.,
@trpc/react-query,@trpc/client) and API shape change across major releases. Pin your@trpc/*versions and keep the example snippets in sync.
Common Mistakes to Avoid
- Exporting the router instance instead of its type:
export const appRouter = t.router(...)should be paired withexport type AppRouter = typeof appRouter;. The client must receive the type, not the runtime object. - Missing
QueryClientProviderortrpc.Provider– the hooks will throw at runtime. Wrap your app with both providers. - Using mismatched
zodversions in shared validation packages – the server may accept data that the client’s inferred type rejects, or vice versa. - Assuming the client can read the router’s
contextor internal state – context is server‑only and should not contain secrets or non‑serializable objects. - Relying on automatic caching defaults for all queries – React Query still applies
staleTime,retry, etc. Tune them per endpoint.
How to Verify Your Setup
- Run
npx tsc --noEmit– any wrong argument or missing field will surface as a compile error. - Open the browser’s Network tab – trigger two queries in the same render. With
httpBatchLinkyou should see a singleGET /trpc?queries=…request. Switch tohttpLinkand confirm two separate requests. - Return a
Datefrom the server. Withoutsuperjson, the client receives a string. Addsuperjsonto both links and confirm the value is aDateagain. - Check
package-lock.jsonorpnpm-lock.yamlfor the installed@trpc/*versions. Verify that the import paths you use match those versions.
When to Use This Pattern
If your team wants end‑to‑end type safety, fast iteration, and minimal build tooling, the React Query integration is ideal. It removes the need for a separate client generator, keeps the API surface in a single place, and leverages React Query’s mature caching and refetching features.
However, if your API must expose data to third‑party clients (e.g., mobile apps, other services), you’ll need a separate, language‑agnostic contract (OpenAPI, GraphQL, etc.). In that case, tRPC remains a great internal tool but should not be the sole public API surface.
Conclusion
Export the router type, create a typed React client, and let tRPC + React Query give you fully typed hooks. Keep an eye on transformer usage, batching limits, and version compatibility to avoid subtle bugs. With these practices, your client code will always match the server’s contract at compile time, saving countless debugging sessions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.