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 team stopped writing and babysitting request types and started trusting the SDK. Feature work got faster because an entire category of bug stopped existing.
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.