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