pyRPC started with a deliberately small TypeScript surface: createClient<Types>(), a Proxy, and one POST /rpc per call. That is still the core. What we shipped next are thin framework adapters — React, Next.js, Vue, and Svelte — that put TanStack Query on top of that same client without inventing a second transport.
This post is the architecture deep dive: package layout, why the DX stays flat, how query/mutation kinds work end-to-end, and how Next.js hydration fits.
The invariant: one transport
Every adapter ultimately calls createClient from @pyrpc/client. There is no parallel fetch layer, no links chain, no batcher in v1. That keeps the mental model identical to the vanilla client:
// Vanilla
const client = createClient<Types>({ baseUrl })
await client.greet({ name: "Ada" })
// React (same procedure name, TanStack Query around the Promise)
const api = createReactClient<Types>({ baseUrl })
api.greet.useQuery({ name: "Ada" })If you understand the Proxy client, you understand the adapters. The hooks are glue.
Package map
pyrpc-core @rpc / @rpc.query / @rpc.mutation + JSON-RPC pyrpc-codegen Types + ProcedureKinds + procedureKinds @pyrpc/types generated contract (npm placeholder overwritten by codegen) @pyrpc/client createClient<Types>() — transport @pyrpc/react createReactClient — TanStack Query hooks @pyrpc/next createNextClient — React + RSC prefetch/hydrate @pyrpc/vue createVueClient @pyrpc/svelte createSvelteClient
Dependency direction is strict: framework packages depend on @pyrpc/client, never the reverse. Next depends on React. Vue and Svelte never import React.
Why createReactClient imports ClientOptions
Types still come from @pyrpc/types — that has not changed. Adapters also accept the same runtime config as the vanilla client (baseUrl, headers), so they extend ClientOptions from @pyrpc/client and call createClient internally:
import { createClient, type ClientOptions } from "@pyrpc/client"
export type ReactClientOptions = ClientOptions & {
kinds?: ProcedureKindMap
}
export function createReactClient<T>(options: ReactClientOptions = {}) {
const { kinds, ...clientOptions } = options
const client = createClient<T>(clientOptions)
// Proxy: procedure → { useQuery, useMutation }
}So you import types for config from the client package, and procedure contracts from @pyrpc/types. That split is intentional.
Naming: createNextClient, not createPyRPCNext
We standardized on create*Client:
createClient— transportcreateReactClient/createVueClient/createSvelteClient— hookscreateNextClient— App Router bundle (hooks + caller + prefetch + dehydrate)
tRPC uses names like createTRPCReact / createTRPCNext. We dropped the product prefix in the factory name because the package scope (@pyrpc/next) already says whose API it is. createNextClient is the consistent pyRPC pattern.
Procedure kinds (phase 2)
TanStack Query distinguishes queries and mutations. tRPC encodes that on the server (.query() / .mutation()). pyRPC now does the same:
from pyrpc_core import rpc @rpc.query def get_user(user_id: int) -> dict: ... @rpc.mutation def update_user(user_id: int, name: str) -> dict: ... # Bare @rpc defaults to kind "query" (backward compatible)
Introspection includes kind. Codegen emits:
export interface Types { ... }
export type ProcedureKinds = {
get_user: "query"
update_user: "mutation"
}
export const procedureKinds = { ... } as const satisfies ProcedureKindsPass kinds: procedureKinds into the adapter so TypeScript only exposes the matching hook. Without kinds (legacy), both hooks exist on every procedure — useful during migration, not the long-term default.
Providers
Wrap the app once with TanStack Query’s provider so hooks share a cache. PyRPCProvider / NextPyRPCProvider are thin convenience wrappers around that; Vue uses VueQueryPlugin; Svelte uses Svelte Query’s provider. pyRPC does not add a separate RPC or auth context — baseUrl / headers live on the factory options.
Next.js: why a bundle?
App Router splits server and client. Hooks cannot run in RSC. So createNextClient returns:
api— client hooks (from createReactClient)createCaller— Promise client for Server Components / route handlersprefetch/dehydrate/HydrateClient— RSC → client cache handoffgetQueryClient— request-scoped on the server (Reactcache), singleton in the browser
That is the full adapter — not a docs-only DIY. The pattern matches how serious App Router + React Query apps are structured, with pyRPC’s simpler client underneath.
Query keys and utils
Keys are stable and predictable: ["pyrpc", procedureName, input?]. api.useUtils() exposes invalidate / prefetch / fetch per procedure, the same job as tRPC’s utils without nested routers.
What we deliberately did not copy from tRPC
- Links / httpBatchLink
- Nested procedure routers on the client
- Superjson transformers
- Subscriptions (roadmap item; not in adapters v1)
Alignment means familiar hooks and kinds, not a feature checklist clone. pyRPC’s differentiator stays: Python @rpc → generated Types → one flat client.
Versioning the multi-package surface
npm adapters (@pyrpc/*) should stay on a synchronized version line (today 0.8.x) with peerDependencies on @pyrpc/client and TanStack Query. Python packages (pyrpc-core, adapters, codegen) can move on PyPI versions independently but must document compatible ranges when schema fields (like kind) change.
Practical rule: one feature PR that touches core kinds + codegen + JS adapters ships together; bump the npm workspace packages in lockstep for that release.

pyRPC