In one line
I made the backend's OpenAPI spec the single source of truth for the API and built a pipeline that compiles it into a typed TypeScript SDK, published automatically on every change - so a breaking API change becomes a red build in the frontend instead of a bug a merchant finds.
The problem
When a backend and a frontend live in separate repositories, they drift. A field
gets renamed on the server, the frontend doesn't hear about it, and you find out
in production - or in a long afternoon of "why is this undefined?"
On a payments platform under active development across two repos, that isn't a nuisance, it's a steady tax: hand-written request/response types that quietly lie, runtime errors that should have been compile errors, and a frontend team that can't fully trust the API it's building against. Multiply that by dozens of endpoints under active migration and it becomes a real drag on velocity.
The insight
The backend already knew the exact shape of every endpoint. Handlers are
annotated with OpenAPI attributes, and a generator turns those into an
openapi.yml spec. The spec was an accurate contract. The problem was that
nothing forced the frontend to stay aligned with it - alignment was a manual,
human, forgettable step.
So I removed the human from the loop and made the contract flow downhill, automatically, from one source.
What I built
A code-first OpenAPI pipeline, end to end.
1. The spec is generated from the code that serves the request
Handlers carry OpenAPI attributes; a command (zircote/swagger-php) scans them
and emits openapi.yml. The spec is a build output, not a document someone
maintains by hand and forgets to update.
#[OA\Get(
path: '/customers',
operationId: 'fetchAllCustomer', // ← becomes the SDK function name
tags: ['Customers'],
responses: [
new OA\Response(
response: 200,
description: 'OK',
content: new OA\JsonContent(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Customer')
)
),
],
)]
That operationId is not a detail - it's the contract for the generated client.
With it you get a clean fetchAllCustomer(); without it the generator emits
hash-based garbage like getA3f8b2c1(). Which is exactly why I didn't leave it to
discipline (see below).
2. The contract is linted
The spec is checked with Spectral against a custom ruleset before it's
allowed downstream - every operation must have an operationId, schemas must
live on DTOs (not entities), responses must be typed. The contract stays
machine-consistent instead of slowly rotting.
3. The spec compiles to a typed SDK
@hey-api/openapi-ts turns the spec into a fully typed TypeScript client - every
endpoint, every request and response type, an Axios-based client with auth
interceptors. No interfaces written by hand. The frontend imports functions and
types straight from the package:
import { fetchAllCustomer, findCustomerById, type Customer } from '@org/api-client'
// Fully typed: params, query shape, and the Customer return type all come
// from the backend's OpenAPI spec. Rename a field server-side and this stops
// compiling.
const { data } = await fetchAllCustomer({
query: { pageIndex: 1, resultsPerPage: 10, search: 'acme' },
})
const customer: Customer = await findCustomerById({ path: { id: 123 } })
4. Publishing is automated on change
A GitHub Actions pipeline regenerates the client and diffs it against what's committed; if it differs, it publishes a new version to a private registry, with the version bump derived from conventional commits. The frontend consumes it like any other dependency - no one runs a manual "regenerate the types" ritual, because there isn't one.
Closing the last gap: enforcing the rule that makes it work
The whole pipeline hinges on every endpoint having a correct operationId - a
thing humans forget. So rather than rely on review to catch it, I made it a
custom static-analysis rule in the backend: a handler without a proper
operationId fails CI. The convention that the automation depends on is itself
automated. (More on that approach in
Making architecture enforce itself.)
The payoff
- Drift becomes structurally impossible. The frontend's types are the backend's contract, so it cannot silently fall behind.
- A breaking change becomes a red build. Rename a field on the server and the next client regen turns every affected call site into a TypeScript error - caught before merge, not by a merchant.
- The boring, error-prone work disappeared. The frontend stopped hand-writing and babysitting request types and consumed the generated SDK instead. An entire category of bug - the API returns X, the frontend expects Y - stopped being possible, because the types are generated from the same spec the API serves.
This is the kind of infrastructure that never shows up in a demo and quietly pays off on every endpoint added after it. I wrote about the general approach in Auto-generating a TypeScript API client
- this is what it looks like wired into a real platform, with the linting and CI that make it trustworthy.