"tRPC Looks Great, But Our Backend Is Python"
This is one of the most common reasons teams give for not adopting tRPC, and it's a fair one. tRPC needs both ends of the wire to import the same TypeScript symbol. The moment your backend is Go, Python, Rust, or a Node service in a separate repo with separate deploys, that requirement falls apart.
The replacement is older than tRPC and more boring: OpenAPI, formerly Swagger. A spec file that describes every endpoint, request body, response shape, and error code your API supports. Frontends use it to generate types. Mobile teams use it to generate clients. Backend teams use it as the document a public API consumer reads.
The interesting thing is how close to the tRPC experience you can get with modern OpenAPI tooling — the moment you stop hand-editing the YAML and start generating it from the code that already exists.
The Spec Is The Contract, The Code Generates The Spec
The bad version of OpenAPI is the one where you write a 4000-line YAML file by hand, ship it, and then it slowly drifts away from what the server actually does. Every team that's been burned by Swagger has been burned by this.
The good version inverts the flow: your backend code is the source of truth, and the OpenAPI document is generated from it. The framework you use determines how natural this feels.
- FastAPI (Python) — generates
/openapi.jsonautomatically from your function signatures and pydantic models. Free, no extra annotations. - NestJS (Node) —
@nestjs/swaggerdecorators on your DTOs and controllers produce a spec at/api-json. - Hono with
@hono/zod-openapi— define routes with zod schemas and the spec is built as a side effect. This is my default for new TypeScript backends now. - Spring Boot (Java) —
springdoc-openapireads your annotations and emits a spec. - Go —
huma,kin-openapi, orchiwithswaggocover most cases.
A small Hono example, since it shows the pattern most cleanly:
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
role: z.enum(['admin', 'member']),
});
const getUser = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: z.object({ id: z.string().uuid() }),
},
responses: {
200: { content: { 'application/json': { schema: UserSchema } }, description: 'OK' },
404: { description: 'Not found' },
},
});
const app = new OpenAPIHono();
app.openapi(getUser, async (c) => {
const { id } = c.req.valid('param');
const user = await db.user.findUnique({ where: { id } });
if (!user) return c.json({ error: 'Not found' }, 404);
return c.json(user, 200);
});
app.doc('/openapi.json', { openapi: '3.1.0', info: { title: 'API', version: '1.0.0' } });
The spec at /openapi.json is now in lockstep with the runtime behavior — the same zod schemas validate requests, shape responses, and emit the document.
Generating Frontend Types Without A Bloated Client
Older tools like Swagger Codegen produced enormous generated client classes — thousands of lines of code, lots of inheritance, separate model files for every type. They worked, but they aged badly.
The modern lightweight approach is openapi-typescript. It generates a single .d.ts file containing types only — no runtime code. You combine that with openapi-fetch, a tiny typed wrapper around the platform fetch API, and you get a client that weighs about 5 KB.
npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts
Add it to package.json as a script and run it whenever the backend updates the spec:
{
"scripts": {
"api:types": "openapi-typescript http://localhost:8080/openapi.json -o src/api/schema.d.ts"
}
}
Some teams check the generated file into git so the diff shows up in code review. Others regenerate it in CI on every build. Either is fine. The thing that matters is that running the script is the only step between "backend changed" and "frontend knows."
Calling The API With openapi-fetch
openapi-fetch reads the generated paths type and turns it into a typed GET, POST, PUT, DELETE API. Path strings are autocompleted. Query params are typed. Response bodies are narrowed by status code.
// src/api/client.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema';
export const api = createClient<paths>({ baseUrl: 'https://api.example.com' });
// src/components/UserList.tsx
import { api } from '@/api/client';
export async function fetchUsers() {
const { data, error, response } = await api.GET('/users', {
params: { query: { limit: 10 } },
});
if (error) {
console.error('fetch failed', response.status, error);
return [];
}
return data.users.map((u) => ({ id: u.id, name: u.name }));
}
If the backend renames users to members and you regenerate the schema, this file fails to type-check on the next save. Same end result as tRPC — broken contract becomes a compile-time error — but achieved through a JSON document instead of a shared type symbol.
Other Generators Worth Knowing
openapi-typescript is my default, but the ecosystem has alternatives worth picking based on what you need:
orval— generates TanStack Query hooks from an OpenAPI spec. If you want theuseQuery/useMutationergonomics tRPC gives you out of the box, this is the closest equivalent.hey-api/openapi-ts— a fork of the olderopenapi-typescript-codegenwith active maintenance. Generates a more traditional client class plus types.kubb— a plugin-based generator that can emit types, clients, and even MSW mocks from one spec.
Pick based on whether you want types only (openapi-typescript), types plus a fetch wrapper (openapi-fetch on top), or full TanStack Query integration (orval). All three are healthy projects.
The Operational Workflow
The thing teams underestimate is the loop between "backend changes" and "frontend updates." A workflow that holds up under teams:
- Backend developer changes a route, the spec at
/openapi.jsonupdates automatically. - CI publishes the new spec — usually as an artifact on the backend repo, or by deploying a staging environment the frontend can hit.
- Frontend developer runs
npm run api:typesand commits the regeneratedschema.d.ts. - TypeScript flags every component that uses a renamed or removed field.
- PR is reviewed with the schema diff visible alongside the code diff.
Step 3 is the only manual one, and you can automate it with a Renovate-style bot that opens a PR whenever the spec changes. Some teams do that. Most don't bother because regenerating types is a 1-second command.
Versioning And Deprecation
OpenAPI gives you something tRPC structurally can't: a stable, documented contract that survives independent deploys. If your frontend and backend ship on different schedules, you can:
- Mark fields
deprecated: truein the spec and have the generator emit@deprecatedJSDoc tags. - Run two API versions side by side at
/v1/...and/v2/...and migrate clients incrementally. - Hand the spec to a third-party developer and let them generate a Swift, Kotlin, or Go client from it without your involvement.
This is the trade-off. tRPC is faster for tight monorepos. OpenAPI is more durable for anything that crosses team or language boundaries.
A Mental Model
OpenAPI is what you use when "share a TypeScript symbol" is not an option. The cost is a codegen step and a JSON document you keep regenerated. The reward is the same end-to-end type safety, plus a contract you can publish, version, and hand to clients written in languages you've never used.



