"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.json automatically from your function signatures and pydantic models. Free, no extra annotations.
  • NestJS (Node)@nestjs/swagger decorators 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-openapi reads your annotations and emits a spec.
  • Gohuma, kin-openapi, or chi with swaggo cover most cases.

A small Hono example, since it shows the pattern most cleanly:

TypeScript
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.

Bash
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:

JSON
{
  "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."

Diagram showing a backend (FastAPI, NestJS, or Hono with zod) emitting an openapi.json document, which then fans out to a TypeScript types generator producing a schema.d.ts file consumed by the React frontend, plus a separate arrow to mobile and third-party clients — illustrating one contract serving many consumers across language boundaries.
One spec, many clients — the contract that survives across languages and repos.

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.

TypeScript
// src/api/client.ts
import createClient from 'openapi-fetch';
import type { paths } from './schema';

export const api = createClient<paths>({ baseUrl: 'https://api.example.com' });
TSX
// 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 the useQuery/useMutation ergonomics tRPC gives you out of the box, this is the closest equivalent.
  • hey-api/openapi-ts — a fork of the older openapi-typescript-codegen with 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:

  1. Backend developer changes a route, the spec at /openapi.json updates automatically.
  2. CI publishes the new spec — usually as an artifact on the backend repo, or by deploying a staging environment the frontend can hit.
  3. Frontend developer runs npm run api:types and commits the regenerated schema.d.ts.
  4. TypeScript flags every component that uses a renamed or removed field.
  5. 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: true in the spec and have the generator emit @deprecated JSDoc 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.