When I was primarily a frontend engineer, I had a terrible relationship with APIs. If I wasn't manually rewriting types and interfaces from the backend, I was debugging runtime errors because someone changed an endpoint without telling me.
Back then I read the following advice:
'The best way to get types from the server was to get the types from it.'
What I didn't realize was this was some of the worst advice I've ever received; until I became the backend engineer responsible for maintaining both sides.
The code you're about to see isn't a hypothetical example. It's the actual technical debt that almost burned me out as a solo engineer building a startup.
I didn't understand how the backend was supposed to work in a real world environment.
So this is what I came up with:
// Are you telling me I also need the backend to JUST get some schemas?
import {
SignInWithEmailOrUsernameResponseSchema,
SignInWithEmailOrUsernameSchema
} from "@/backend/validations/auth";
// What is this? Your own flaky OpenAPI schema?
import { AuthAPI } from "../services/auth";
export function useSignIn() {
return useMutation<
z.infer<typeof SignInWithEmailOrUsernameResponseSchema>,
AxiosError<ErrorResponse>,
z.infer<typeof SignInWithEmailOrUsernameSchema>
>({
mutationFn: (user) => AuthAPI.signIn(user),
onSuccess: (data) => {
const { accessToken } = data;
// Only God knows how this was processed...
}
});
}
The Tragedy
You might be wondering. Francisco, what is wrong with this, really? Everything, at least when you're working with both a separate backend and frontend.
โ 1. Coupling
You're assuming your monolith repository will exist forever. But what happens if:
The backend moves to another repository?
You transition to microservices?
You need to support multiple client applications?
The backend team starts using languages other than TypeScript?
๐ 2. Compilation and build nightmares
// Wait, why is my frontend bundle including this?
import { UserEntity } from "@/backend/entities/User";
import { PasswordHelper } from "@/backend/utils/crypto";
import { DatabaseConfig } from "@/backend/config/database";
Bundle Bloat: Your frontend is now shipping backend entities, utilities, and configuration files
Build Complexity: Your CI/CD now needs the entire backend codebase just to build the frontend
Dependency Hell: Backend-specific packages leaking into frontend builds
Docker Disasters: Trying to untangle this mess in containerization
โ 3. Hardcoding and context switching
This was the workflow with the former setup:
Add the new API endpoint
// This needs to be extended or changed manually ๐ญ
export const AUTH_API_ENDPOINTS = {
LOGIN: `/${API_ENDPOINT_NAMES.AUTH}/login`,
REGISTER: `/${API_ENDPOINT_NAMES.AUTH}/register`
};
Create the new API service
// Yes, we also need backend dependencies and duplicate logic!
const signIn = api<
z.infer<typeof SignInRequest>, // โ Manual type inference
z.infer<typeof SignInResponse> // โ More manual work
>({
method: "POST",
path: AUTH_API_ENDPOINTS.LOGIN, // โ Hardcoded string
requestSchema: SignInRequest, // โ Duplicated validation
responseSchema: SignInResponse, // โ More duplication
type: "public",
withCredentials: true,
});
Create the queries and mutations
You already saw the code in the introduction, but see how duplicate things are and how prone this is to break, again.
export function useSignIn() {
return useMutation<
z.infer<typeof SignInWithEmailOrUsernameResponseSchema>,
AxiosError<ErrorResponse>,
z.infer<typeof SignInWithEmailOrUsernameSchema>
>({
mutationFn: (user) => AuthAPI.signIn(user),
onSuccess: (data) => {
const { accessToken } = data;
// Why are we guessing types?
}
});
}
Now pray that something does not break and repeat the same exact process for 60+ API endpoints... right?
There's a BETTER way
I performed a refactor at work where we migrated all this Zod chaos, DTOs, schema validations, and backend spaghetti into a proper API contract.
Before I walk you through the recovery, let me introduce the tool that changed everything: OpenAPI.
OpenAPI
OpenAPI is a specification format used to describe and document REST APIs in a way that can be understood by human and used by machine. Itโs not just documentation, but also a contract.
Hereโs what it gives you out of the box:
๐บ 1. Available endpoints:
Every route, method, and path is clearly defined.
๐ 2. Query and path params for each API endpoint:
You know exactly what each endpoint expects.
โ
3. Response format for each status code:
You can validate what success, failure, and edge cases look like.
We'll see now how we can create a professional API specification on the server side.*
Generating OpenAPI Schemas
This process involves generating a .json or .yaml file containing your API contract.
Warning: This step varies dramatically based on:
The scale of your project (monolith vs microservices)
Your framework (Nest.js vs Spring Boot vs Go)
Your team's technical maturity (senior engineers vs junior team)
Your deployment complexity (do you need API versioning?)
The Nest.js Approach: Decorator-Driven
In Nest.js you use decorators to automatically generate your OpenAPI spec:
// task.controller.ts
@Controller('tasks')
export class TasksController {
@ApiTags('Tasks')
@Get()
@ApiOkResponse({
type: TaskEntity,
isArray: true,
description: 'Returns all tasks for the authenticated user'
})
@ApiUnauthorizedResponse({ description: 'Invalid or missing token' })
findAll() {
return this.tasksService.findAll();
}
}
// task.entity.ts
export class TaskEntity {
@ApiProperty({
type: String,
description: 'Unique task identifier',
example: '550e8400-e29b-41d4-a716-446655440000'
})
id: string;
@ApiProperty({
type: String,
description: 'Task title',
example: 'Finish API documentation',
maxLength: 255
})
title: string;
@ApiProperty({
type: Date,
description: 'When the task was created',
example: '2024-01-15T10:30:00.000Z'
})
createdAt: Date;
}
The Reality Check
If look at Reddit or technical blogs/forums, some developers prefer writing OpenAPI specs manually. The code-first approach has trade-offs:
โ Pros:
- Single source of truth (your code generates docs)
- Harder for docs to drift from implementation
- Faster iteration in early-stage startups
โ Cons:
- Can get messy with complex APIs
- Limited control over exact OpenAPI features
- Decorator pollution in your business logic
The Framework for Success
- Mirror your domain entities and find a DRY way to manage them
- Document controllers methodically. Descriptions and examples are important too
- Display with SwaggerUI or Scalar for team collaboration
- Generate client SDKs automatically as part of your CI/CD
Choosing Your API Visualizer
Honestly? Whatever. The important thing is that you have a visual interface for your API. Choose the one that's the most suitable for your team needs. Let's explore the common options with more detail:
Swagger UI - The battle-tested veteran
- โ Universally recognized, extensive customization
- โ Can feel dated, heavier bundle
Scalar - The modern contender
- โ Beautiful UI, faster, better DX
- โ Embracing by major frameworks (.NET's new default)
- โ Less ecosystem maturity
โญ Bonus: Frontend Implementation
Now you might be wondering, how does this actually work on the frontend?
Well, itโs not difficult.
Let me introduce you to OpenAPI TypeScript
Imagine if your axios or fetch client had complete type safety and could interact with your API without manual guesswork. Thatโs exactly the problem OpenAPI TypeScript solves. All you need is the OpenAPI specification, which can be from your backend or a remote URL.
๐ Getting Started with OpenAPI TypeScript
- Install the tool
npm install -D openapi-typescript
- Generate your types
From a local schema:
npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# ๐ ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]
From a remote schema:
npx openapi-typescript https://myapi.dev/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# ๐ https://myapi.dev/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]
Now you can import types and contracts directly from your schema:
import type { paths, components } from "./my-openapi-3-schema";
// Schema object
type MyType = components["schemas"]["MyType"];
// Path parameters
type EndpointParams = paths["/my/endpoint"]["parameters"];
// Response objects
type SuccessResponse =
paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];
Wait, what about type-safe API clients?
Even with generated types, youโd still need to manually wire up your API calls!
Thatโs where OpenAPI Fetch comes in.
It gives you a fully type-safe client. No more guessing and no more duplicated logic.
Example: Type-Safe API Calls
import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const {
data, // only present if 2XX response
error, // only present if 4XX or 5XX response
} = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
},
});
await client.PUT("/blogposts", {
body: {
title: "My New Post",
},
});
This can also be implemented seamlessly with React Query, SWR or you favorite data fetching/mutation library.
Conclusion: The Black-Box
Please, always remember this:
Your frontend application must treat the backend as a black box.
It does not care about your database schema, crypto helpers, or specific implementation details. It only sees the API and the public interfaces.
By forcing your entire system to rely on a generated, language-agnostic OpenAPI Contract, you achieve true separation. You replace the questionable monorepo setup with an actual handshake.
This is the difference between a codebase that scales and a codebase that burns you out.
Stop debugging what you didn't write and start building what you control.
๐ Join Escaping From the Abyss
Thank you so much for reading! :)
Iโm launching a newsletter where I share hard-earned lessons from the trenches of startup engineering, especially in backend development and product planning.
Iโve seen engineers suffer from broken workflows and scope creep. Iโve even lived it. And I believe every dev deserves clarity, autonomy, and the tools to build well; both technically and emotionally.
This isnโt just about clean code. Itโs about clean contracts, clean boundaries, and clean teams.
Letโs make startups a better place, through better engineering, better business practices, and better mentorship.
Join me here! Letโs escape from the abyss.


Top comments (0)