DEV Community

Cover image for API First in Practice: How We Made Frontend Types Predictable and Stable
Dmitrii Verbetchii
Dmitrii Verbetchii

Posted on

API First in Practice: How We Made Frontend Types Predictable and Stable

In many teams, frontend and backend evolve in parallel — and very often, they drift apart.
I’ve seen this happen multiple times: API changes, frontend assumptions stay the same, and bugs appear only after the release.

In our company, we decided to treat this problem as a process issue, not just a tooling one. The solution we landed on was an API First approach, where the frontend relies strictly on the API contract defined by the backend — nothing more, nothing less.

This article is about how we implemented this approach in practice and what it changed for our frontend development.

The Problem: Frontend–Backend Desynchronization

Before adopting API First, our workflow looked familiar:

  • backend changes an endpoint
  • frontend still uses old assumptions
  • manual TypeScript types become outdated
  • bugs appear late — sometimes only in production

Even with good communication and documentation, the reality was simple:
the frontend always found out about breaking changes too late.

TypeScript helps, but only if the types are accurate. And manually maintaining those types doesn’t scale.

Our Solution: API First as a Contract, Not Documentation

In our team, API First means one simple rule:

The frontend uses only the TypeScript types and interfaces generated from the backend API.

The OpenAPI (Swagger) schema is not just documentation.
It is the single source of truth.

If something is not described in the API contract, the frontend doesn’t assume it exists.

How It Works in Practice (CRUD User Example)

Let’s take a simple CRUD example: users.

Before the backend implementation is even finished, the backend team provides us with an OpenAPI schema in YAML format describing the future API.

Here is a simplified but real example of such a schema:

openapi: 3.0.3
info:
  title: User Service API
  description: API for managing users
  version: "1.0"

paths:
  /users:
    get:
      tags:
        - users
      summary: Get list of users
      operationId: listUsers
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
        - name: status
          in: query
          schema:
            type: string
            enum: [active, inactive]
      responses:
        "200":
          description: List of users
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserListResponse"

    post:
      tags:
        - users
      summary: Create new user
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"

  /users/{id}:
    get:
      tags:
        - users
      summary: Get user by ID
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: User data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
          format: email
        status:
          type: string
          enum: [active, inactive]

    CreateUserRequest:
      type: object
      required:
        - name
        - email
      properties:
        name:
          type: string
        email:
          type: string
          format: email

    UserListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        total:
          type: integer
Enter fullscreen mode Exit fullscreen mode

This YAML is enough for the frontend to start working — even if the backend implementation is still in progress.

Generating TypeScript Types and API Requests

On the frontend, we use openapi-generator to generate TypeScript code from this schema.
The generator gives us:

  • TypeScript types and interfaces
  • API request functions

We don’t write axios calls manually anymore.
We don’t guess request shapes.
We use only what was generated.

For example:

  • getUser clearly returns a User
  • createUser expects CreateUserRequest This makes the frontend code extremely predictable.

Generated Types as the Foundation of Frontend Code

Once the types are generated, they are used everywhere:

  • API layer
  • React components
  • Forms
  • Validation
  • UI state

A very practical example is enums.
From the OpenAPI schema, we get a generated enum like this:

export const UserStatusEnum = {
  Active: 'active',
  Inactive: 'inactive',
} as const;

export type UserStatusEnum =
  typeof UserStatusEnum[keyof typeof UserStatusEnum];
Enter fullscreen mode Exit fullscreen mode

Now the frontend knows that User.status can only be "active" or "inactive".

We use this enum to:

  • build dropdown options
  • create filters
  • drive conditional UI logic

No hardcoded strings.
No duplicated constants.
No silent mismatches.

CI as an Early Warning System

This approach really shines once CI is involved.

Our process looks like this:

  • frontend regularly fetches the latest Swagger schema
  • TypeScript types are regenerated
  • CI runs tsc and frontend unit tests

If the backend changes something unexpectedly:

  • generated types change
  • TypeScript compilation fails
  • unit tests fail

The important part is when this happens.

Not after the release.
Not in production.
But during CI.

This means backend and frontend developers are aware of the problem immediately.
Frontend tests become an additional safety net for backend changes.

What We Gained from This Approach

After adopting API First, we noticed clear improvements:

  • predictable and stable frontend types
  • faster development
  • fewer questions between teams
  • earlier detection of breaking changes
  • much higher confidence when refactoring

The frontend stopped guessing and started trusting the contract.

How to Introduce This in Another Team

If you want to try this approach, my advice would be:

  1. Start with one service
  2. Generate types first, requests later if needed
  3. Enforce usage of generated types
  4. Add type checks to CI
  5. Treat OpenAPI as a contract, not documentation

This is less about tools and more about discipline.

Final Thoughts

API First is not about Swagger files or generators.
It’s about predictability.

For a TypeScript frontend, having a reliable contract changes how you think about data.
When the API is the source of truth, the frontend becomes simpler, safer, and more confident.

Top comments (1)

Collapse
 
art_light profile image
Art light

This is a strong, practical example of API First done right: treating OpenAPI as a strict contract and generating types removes ambiguity, enforces alignment, and turns CI into an early warning system. The real win isn’t the tooling—it’s the discipline that makes frontend types predictable, refactors safer, and cross-team changes visible immediately.