Making openapi.yaml the source of truth for backend and frontend
At this point, it felt like the next obvious step was bringing the same end-to-end safety idea to the API layer too…
Previously I had already made the database-to-backend flow more type-safe. After that, I started thinking about the next boundary that can easily drift over time: backend and frontend. I do not want the backend to expect one thing while the frontend assumes something slightly different. That is the kind of mismatch that usually starts small, then becomes annoying later.
The idea here was heavily inspired by @boristane and the “ship types, not docs” direction. The core idea clicked with me pretty quickly: the schema should be the source of truth. In this project, that schema is openapi.yaml, so I wanted that file to become the thing that generates type-safe outputs for both backend and frontend.
In this commit, I moved in that direction.
Funny enough… there is no visible migration commit from kin-openapi to oapi-codegen because that part only happened locally on my machine and never made it into version control 😅
Before settling on this approach, I tried integrating kin-openapi, but after using it for a bit, I realized it was not really what I wanted. It validates things at runtime, which is useful, but it still does not give me that immediate feedback in the editor. There is no red squiggly line when my Go request/response types do not match what openapi.yaml expects, and the same problem applies on the frontend too.
That is why I switched direction and used oapi-codegen.
On the backend side, I added an oapi command in the Makefile so I can generate Go structs from openapi.yaml based on my oapi-codegen.yaml config. On the frontend side, I also added a generate:api command so TypeScript types can be generated too, with the output going into frontend/src/lib/api/v1.d.ts.
So the workflow is pretty straightforward now: whenever openapi.yaml changes, I need to rerun make oapi in the backend and pnpm generate:api in the frontend. That way, both sides stay aligned with the same source of truth.
I also did not want this to rely only on manual discipline, so I added CI checks for drift whenever there is a push or PR to the main branch. On the backend job, CI installs oapi-codegen, regenerates the Go types from openapi.yaml, then fails if api/gen/api.gen.go has a diff. On the frontend side, CI runs pnpm generate:api and fails if frontend/src/lib/api/v1.d.ts is out of date. I also kept the usual checks there too, like go build, go vet, and pnpm check.
That part matters a lot to me. Generating types locally is nice, but adding drift checks in CI makes the contract harder to accidentally break. If someone changes the schema but forgets to regenerate the generated files, the mismatch gets caught immediately instead of quietly slipping into the branch.
What I like about this change is that it pushes the project closer to a cleaner contract-first setup. Instead of docs being something people read and then manually interpret into code, the schema directly shapes the code itself. With the extra CI guardrail in place, openapi.yaml now feels much closer to a real source of truth… not just in theory, but in the actual workflow too.