Skip to main content

Command Palette

Search for a command to run...

Streamlining API Development: Generating API Client from Swagger Documentation

Enhancing Developer Efficiency with Automated API Client Generation

Updated
9 min read
Streamlining API Development: Generating API Client from Swagger Documentation

At Ablo, we build products that operate at the intersection of e-commerce, design, and scale. Our core platform consists of one main frontend application and one main backend service, but our architecture extends beyond that. We also develop dedicated projects for enterprise customers, each with its own repository and deployment lifecycle, while still depending on our main backend APIs. This hybrid setup gives us flexibility, but it also introduces additional complexity when it comes to API contracts and consistency.

On the backend, we use NestJS, and we rely on @nestjs/swagger to generate OpenAPI definitions directly from code annotations. Like many teams, we initially treated Swagger primarily as a documentation tool. Swagger UI became the default reference point for frontend developers, and we maintained a Postman collection for testing and exploration. While this setup worked, it positioned our API definitions as a reference rather than a source of truth.

As the team grew and we started delivering features at a faster pace, we also found ourselves frequently updating and refactoring existing logic. This rapid development cycle made it increasingly difficult to track API changes and catch breaking updates early. Without a strongly enforced contract between backend and frontend, small changes could slip through unnoticed, leading to inconsistencies that were time-consuming to debug and reduced overall development confidence.

This post explains how we moved from “Swagger UI as documentation” to automated OpenAPI generation and consumption across environments, and how that shift fundamentally improved our API reliability, frontend integration, and overall developer experience.

Our SwaggerUI docs

Our Setup

On the backend, our architecture is built heavily around NestJS. We use @nestjs/swagger to generate OpenAPI definitions directly from decorators and annotations in our codebase. For request validation and data transformation, we rely on class-validator and class-transformer, which gives us strong runtime guarantees for incoming data. We manually defined DTOs for most endpoints, but this practice was not consistently enforced across the codebase. In many cases, we had well-defined input types and validations, but response schemas were either loosely defined or missing altogether.

On the frontend, we maintained our own TypeScript interfaces to represent backend responses. These types were manually updated and not automatically synchronized with the backend. As the API evolved, this led to gaps: some fields were missing, others were deprecated but still referenced, and certain commonly used entities existed in multiple slightly different definitions. API requests were constructed manually, along with response type casting, and frontend-side validation was minimal.

This setup worked in the early stages, but as the number of endpoints and feature iterations increased, the lack of a single, enforced API contract between backend and frontend started to create friction and uncertainty across teams.

@ApiOperation({ description: 'Get list of brands with paginantion' })
@ApiQuery({ type: GetBrandsQuery })
@ApiResponse({
  status: HttpStatus.OK,
  type: BrandDto[]
})
@UseGuards(UserGuard)
@ApiSecurity('auth')
@Get()
@UsePipes(new ValidationPipe())
async getBrand(
  @Request() req: UserAuthRequest,
  @Query() query: GetBrandsQuery,
): Promise<BrandDto[]> {
  // Service Calls
}

Automating API Client Generation

One of the key decisions we made was to keep the backend changes minimal. We didn’t introduce any new packages or tooling on the backend. Since we were already using @nestjs/swagger, all the information we needed was already there. Instead, we exposed a new internal endpoint that returns the JSON version of our OpenAPI 3.0 specification. To secure this endpoint, we added a simple header-based secret check, ensuring that the schema is only accessible to internal tooling. This approach allowed us to treat the OpenAPI definition as a first-class artifact of our backend without changing how developers write endpoints. The backend continues to generate the schema from annotations, but now it can also be consumed programmatically.

const allDocs = SwaggerModule.createDocument(
  app,
  new DocumentBuilder()
    .setTitle('Ablo API')
    .build(),
  { include: [...publicModules, ...privateModules] }
)
app.use('/', (req: Request, res: Response) => {
  if (req.headers['secret'] !== SECRET) {
    return res.status(401).send({ message: 'Unauthorized' })
  }
  res.setHeader('Content-Type', 'application/json')
  res.send(allDocs)
})

On the frontend, we introduced swagger-typescript-api to convert the OpenAPI JSON schema into fully typed TypeScript API clients and models. This tool generates request functions, response types, and shared models directly from the OpenAPI definition, removing the need for manual type maintenance. The generated output becomes the single source of truth for frontend–backend communication.

To make this process repeatable and easy to run, we added a small set of NPM scripts to our frontend project. These scripts fetch the latest OpenAPI schema from the backend, format it, and generate TypeScript definitions automatically:

// package.json
{
  "api": "npm run api:fetch && npm run api:format && npm run api:generate",
  "api:fetch": "curl -s $API_DOCS_URL -o ./src/api/docs.json",
  "api:format": "prettier --write ./src/api/docs.json",
  "api:generate": "npx swagger-typescript-api generate --path ./src/api/docs.json -o ./src/api"
}

Once generated, the API is consumed through a single, centralized client instance. This client handles base configuration and shared security concerns, such as authentication headers, so individual API calls remain clean and consistent:

// src/api/index.ts
const AbloAPI = new Api({
  baseUrl: Config.API_URL,
  securityWorker: (data) => {
    return {
      headers: {
        Authorization: `Bearer ${data?.token}`,
      },
    };
  },
});

With this setup in place, consuming backend APIs on the frontend becomes a simple, type-safe function call. For example, fetching brands no longer requires manually constructing requests or maintaining separate response types:

// src/pages/brand.ts
const brands = await AbloAPI.brands.brandControllerFindAll({
  isFeatured,
  limit,
  skip,
});

All request and response definitions are automatically generated inside Api.ts. Each endpoint includes typed query parameters, return types, and metadata derived directly from the OpenAPI schema:

// src/api/Api.ts
...
brands = {
  /**
   * Get list of brands with paginantion
   *
   * @tags Brands
   * @name BrandControllerFindAll
   * @request GET:/brands
   */
  brandControllerFindAll: (
    query: {
      active: boolean;
      limit: number;
      skip: number;
    },
    params: RequestParams = {},
  ) =>
    this.request<BrandDto[], any>({
      path: `/brands`,
      method: "GET",
      query: query,
      format: "json",
      ...params,
    }),
...

Challenges During Adoption

Challenge 1: Non-Standard Endpoint Definitions on the Backend

Our first major obstacle was consistency on the backend. Although we were already generating OpenAPI definitions through @nestjs/swagger, our controller implementations were not standardized. Over time, endpoints accumulated a large number of decorators often applied inconsistently across controllers. To solve this, we introduced a single, standardized decorator that encapsulates the full endpoint definition: routing method and path, Swagger response definitions, request body/query/param metadata, guards, roles, and additional decorators. Below is the core implementation of our EndpointDefinition decorator. It converts a single definition object into the appropriate NestJS and Swagger decorators and applies them via applyDecorators:

// src/endpoint/index.ts
export function EndpointDefinition(
  definition: EndpointDefinition
): MethodDecorator {
  const decorators: Array<
    ClassDecorator | MethodDecorator | PropertyDecorator
  > = []
  switch (definition.method) {
    case HttpMethod.get:
      decorators.push(Get(definition.path))
      break
    ...
  }
  definition.responses.forEach(response =>  decorators.push(ApiResponse(response)))
  if (definition.body) decorators.push(ApiBody(definition.body))
  if (definition.query) decorators.push(ApiQuery(definition.query))
  if (definition.params) decorators.push(ApiParam(definition.params))
  decorators.push(ApiOperation({ description: definition.description }))
  decorators.push(UseGuards(...definition.guards))
  decorators.push(...definition.extra)
  return applyDecorators(...decorators)
}

Once this abstraction was in place, controller methods became significantly easier to scan and maintain. For example, the getBrands endpoint moved from a long list of annotations into a single reusable definition that fully describes the endpoint contract:

// src/definitions/brand.ts
export class GetBrandsResponse extends BrandDto {}
export class GetBrandsQuery {
  // Properties for creating a brand
}

export const GetBrandsDefinition = EndpointDefinition({
  method: HttpMethod.get,
  path: '/brands',
  description: 'Get list of brands with paginantion',
  guards: [UserGuard],
  responses: [
    { status: HttpStatus.CREATED, type: GetBrandsResponse, isArray: true }
  ],
  extra: [UsePipes(new ValidationPipe())]
})
// src/controllers/brand.ts
@GetBrandsDefinition
async getBrand(
  @Request() req: UserAuthRequest,
  @Query() query: GetBrandsQuery,
): Promise<GetBrandsResponse[]> {
  // Service Calls
}

This pattern gave us two immediate benefits. First, controllers became cleaner and more uniform, which reduced review overhead and made it easier to spot missing pieces. Second, the OpenAPI schema quality improved because endpoint metadata was consistently defined in one place.

Challenge 2: Adopting Generated API Types on the Frontend

The second major challenge emerged on the frontend once we started consuming the generated OpenAPI types. Before this change, we relied on a small set of manually maintained TypeScript interfaces to represent most backend entities. These types were shared across multiple endpoints, pages, and components, and over time they drifted away from the actual API behavior. In particular, many fields that were optional in practice were not marked as optional in TypeScript, which masked inconsistencies until runtime.

When we switched to using the generated API definitions, these mismatches surfaced immediately as TypeScript errors. The compiler started flagging missing fields, incorrect assumptions, and invalid type usage across the application. While this was ultimately a positive outcome, it made the initial adoption phase challenging, especially for entities that were reused extensively across the UI.

To manage this, we avoided a big-bang migration. Instead, we updated the frontend incrementally, focusing on one entity at a time. For each entity, we aligned components, pages, and data flows with the generated types before moving on to the next. This was particularly time-consuming for core entities that appeared in multiple views and business flows, but it allowed us to make progress without blocking feature development.

As a result, the frontend currently uses a mix of manually defined types and generated API types. While this is not the final state we’re aiming for, it provides a practical transition path. Our long-term goal is to rely entirely on generated types as the single source of truth, but we expect this migration to happen gradually as the codebase continues to evolve.

Results

Improved API Standardization and Contract Quality

Standardizing endpoint definitions led us to be much more deliberate about request and response contracts. Responses that were previously implicit or loosely defined are now explicitly documented in OpenAPI, which improved clarity, reduced accidental breaking changes, and made backend development more consistent and reviewable.

Stronger Reliability Between Backend and Frontend

Using generated API clients and types significantly improved reliability between backend and frontend by catching mismatches at compile time instead of runtime. While migrating from manually maintained types introduced some short-term friction and type errors, these issues consistently exposed real inconsistencies and resulted in more robust frontend implementations.

Better Visibility into API Usage and Optimization Opportunities

Typed API usage made it easier to see which endpoints are used by which screens and which response fields are actually required. Although we haven’t fully leveraged this yet, it creates a strong foundation for future optimizations such as reducing response sizes and aligning endpoints more closely with real usage patterns, bringing some GraphQL-like benefits to our REST setup.

Daily Developer Experience

From a frontend perspective, the developer experience has improved notably. Based on Jason’s experience, frontend developers now care less about plugging in API endpoints and can almost immediately start using them by simply wrapping them with React Query. This saves time and allows the team to focus on how data is consumed rather than on correctly fetching it. Additionally, pulling types directly from the OpenAPI Specification has elevated frontend type safety, as changes are now reflected immediately instead of requiring manual updates.

What’s Next

One natural next step is to package our generated API definitions as a versioned NPM package and publish a new release on every merge. This would clearly shift ownership of API contracts to the backend and allow frontend projects to consume a specific, immutable version of the API. The value of this approach increases as the number of backend and frontend services grows, especially in a multi-repo environment.

Another opportunity is reusing these API definitions across our other backend projects. Since some of our enterprise-facing services already depend on the main backend for certain operations, sharing a single, typed API contract would reduce duplication and make cross-service communication more explicit and reliable.

Longer term, we could move away from generating OpenAPI definitions via @nestjs/swagger annotations and instead generate OpenAPI JSON directly from TypeScript types. While this would create a cleaner, type-first contract model, it would require introducing additional tooling on the backend and refactoring all existing endpoints. Given the current cost and limited immediate benefit, this is not something we plan to pursue right now but it remains a potential direction if our architecture evolves further.