Skip to main
Article

Generating Frontend API Clients from OpenAPI

API changes can be a headache in the frontend, but some initial setup can help you develop and adapt to API changes as they come. In this article, we look at one method of using OpenAPI to generate a typesafe and up-to-date frontend API client.

In initial prototypes, it’s easy for frontend devs to place fetch calls directly into a component. As app complexity grows, it becomes helpful to adopt a client pattern. This centralizes logic about authentication and API endpoints, and allows the developer to define reusable patterns for accessing and loading data. Often devs use something like Tanstack Query or RTK Query to define the patterns.

But problems can arise as changes are made to the API. Let’s walk through an example of how this plays out.

  1. The backend adds a new field to the API representing a user’s full name, and removes the first and last name fields.

  2. The API consumers (frontend devs) are hopefully notified of this change… hopefully.

  3. The frontend dev adjusts the client to account for the change.

  4. The frontend dev manually checks and updates all places where the fields are used, and catches everything… hopefully.

While centralizing logic in a client pattern can simplify the process of frontend changes, it still is error prone and takes time. These manually generated clients are incredibly helpful patterns, but contain a lot of boilerplate that is shared between each endpoint.

Luckily, I’m not the only developer wanting an easy abstraction of an API that I can use in the frontend. Of course, all of our APIs are written using different technology, and represent different content. This is where OpenAPI comes in.

You may have encountered OpenAPI through Swagger UI, but an OpenAPI spec of an API contains enough info to generate an entire frontend client for you. A good place to start is with openapi-typescript-codegen, which exposes each API endpoint and method as a function with typed inputs and outputs.

Depending on your tech stack, you may find a more specific codegen tool to create a client that integrates with React Query or RTK Query.

Once you generate the client, the code you need to call the API to create a user may just be:

const response = await UserService.userCreate({ requestBody: userInfo });

A few things this provides:

  1. Type safety and Intellisense hints on what content goes in userInfo means no guessing if it’s first_name or firstName or just name.
  2. There’s no need to know the URL endpoint or method.
  3. Token and auth handling is abstracted away.
  4. Types for response mean you know what data to expect from the API.

As an example, we recently needed to rename a field in a database from tag to title to provide more clarity. Of course, we already had frontend UIs and tests that referred to the tag field. I was preparing for a Find and Replace marathon with lots of manual checks after the backend changes to the API were completed. But when Ed, our backend developer on the project, pushed his changes, he posted this in Slack:

Whoah, renaming Version.tag was so easy. Quick find and replace, run tests and linters, fix a few stragglers, and boom! Done with both frontend and backend in ~20 minutes and with confidence nothing is broken. Really proud of our CI today.

“Confidence nothing is broken” – I like the sound of that.

One challenge we encountered was considering what we should put into version control. In general, you want to keep compiled or derived code out of your git repo, as it can lead to gnarly merge conflicts and cause your CI to screech to a halt.

On the other hand, as we switch between branches for review and bugfixes, it can be a real pain to remember to regenerate the client when you switch branches.

After trying several iterations, we found a process that works:

  1. When a change is made to the API, the developer generates a openapi.json file with the changes, and commits it.

  2. Our CI test suite verifies that the openapi.json file in source code is in sync with the code.

  3. The frontend client is generated from the openapi.json file as part of the build step in CI and locally, and is not committed.

Note:

It’s important that these patterns are enforced by tests and CI, and integrated into our build steps. In addition, we have well-documented commands that run the necessary scripts.

As an added bonus, I’ve found it quite helpful in code review to be able to look at the openapi.json diff in order to quickly understand what changes have been made.

It would also be possible to not commit openapi.json to source control by generating the frontend client directly from a running server. However, this requires us to actually start up the server, which we don’t always want to do, especially for tests in the CI.

Note:

I’ve generally found it easiest to use this pattern when the frontend and backend are in the same repo. This helps keep things in sync and reduces merge conflicts. If you have separate repos, treat the openapi.json and the generated client as ephemeral. Instead of trying to resolve merge conflicts, opt to find a pattern where you can quickly rebuild based on the server.

As we have this set up, we have simplified the “happy path” for API calls – that is, the calls that end in success. While OpenAPI includes the expected errors the API may return, I haven’t found a good way to surface that, or use types to confirm that I’m handling all cases.

For instance, take the unhappy path of a user who tries to verify their email for a second time. The API correctly reports an error, but this is a case where the user failed successfully, and it would be preferable to pretend it succeeded and direct the user to the login page.

The best solution I’ve found is to check that the error is an instance of the generated APIError class, and then check against a string copied from the openapi.json error example.

try {
  await AuthService.verifyEmail({
    requestBody: { token },
  });
  confirmSucceeded();
} catch (error) {
  if (
    error instanceof ApiError &&
    error.body.detail === 'VERIFY_USER_ALREADY_VERIFIED'
  ) {
    confirmSucceeded();
    return;
  }
  showError("Uh oh! We weren't able to confirm your email.");
}

In my dream scenario, I would be to get some error details from Intellisense, and even be able to use a switch case to ensure I’m handling all expected errors that are enumerated in openapi.json.

Creating your frontend client automatically from OpenAPI can make a lot of sense. With just a bit of setup, you can reduce boilerplate and get types for your inputs and outputs, allowing you to deliver functionality to your users more quickly and with fewer bugs.

Recent Articles

  1. A back hoe on the bank of the Suez, trying to free the Ever Given cargo ship
    Article post type

    Learn Grid Now, Container Queries Can Wait

    Take your time with new CSS, but don’t sleep on the essentials

    Several people have asked recently why container queries aren’t being used more broadly in production. But I think we underestimate the level of legacy browser support that most companies require to re-write a code-base.

    see all Article posts
  2. A clear kitchen blender filled with chopped fruit and greens
    Article post type

    Can you un-mix a mixin?

    Rethinking the CSS mixin proposal after CSS Day

    The CSS Working Group has agreed to move forward with CSS-native mixins. But some recent mixin-like CSS tricks have an advantage that the official proposal doesn’t account for: they make it easy to remove a mixin after it’s already been mixed in.

    see all Article posts
  3. Stacks of a variety of cardboard and food boxes, some leaning precariously.
    Article post type

    Setting up Sass pkg: URLs

    A quick guide to using the new Node.js package importer

    Enabling pkg: URLs is quick and straightforward, and simplifies using packages from the node_modules folder.

    see all Article posts