Generating API Clients in Monorepos with FastAPI and Next.js

Anderson Resende
December 12, 2024

Monorepo development challenges developers with complex backend and frontend integrations. Keeping API clients in sync with backend changes often becomes a time-consuming and error-prone process. Wouldn't it be game-changing to have a workflow that automatically updates frontend API clients with minimal manual intervention?

This article explores a streamlined approach to generating type-safe API clients using FastAPI and Next.js. By leveraging @hey-api/openapi-ts for automated client generation, we'll demonstrate a workflow that simplifies API integration, reduces manual updates, and ensures type consistency across backend and frontend components.

The result? Developers can shift their focus from maintaining API interface code to what truly matters: building innovative features. Let’s dive into it.

A real-world problem

Start by imagining a software developer's working day by checking a practical example:

In the example above, we see that the backend API changed due to adding a required field. Since the developer forgot (and this is a very usual mistake) to update the frontend client, our web application crashed.

In a real-world application, if it were deployed, customer support would receive a lot of angry customer tickets to deal with. The development team would have to work on a hotfix.

Integrate backend and frontend with type safety

Now, let’s check how your project could be set up, helping the developers identify precisely what to change on frontend after a backend code change:

In this new example, once the backend API is changed, developers know instantly that something has happened to the frontend build. They don’t even need to try the form again to discover that something will break due to the missing field artist.

The strong typing of the API client, which is a TypeScript client, combined with a JavaScript plugin that shows TypeScript errors in the console and browser, makes this possible. This setup ensures fast feedback loops, allowing developers to quickly identify and address issues, which in turn improves both development velocity and bug prevention.

Export OpenAPI Schema

The key to generating a TypeScript client for our FastAPI backend is its ability to export an OpenAPI schema. Let’s start by checking our data models and endpoints. We have two album Pydantic models and two endpoints for creating and returning the albums.

Notice that since we are using FastAPI, we have a typed API definition; this not only helps in validation and code safety but also allows us to easily export an OpenAPI specification schema.

class AlbumCreate(BaseModel):
    title: str
    artist: Optional[str]
    description: Optional[str]

class AlbumResponse(AlbumCreate):
    id: int
    class Config:
        from_attributes = True

@app.post("/albums/", response_model=AlbumResponse, tags=["albums"])
def create_album(album: AlbumCreate, db: Session = Depends(get_db)):
    # Handles the creation of a new album by taking album details

@app.get("/albums/", response_model=List[AlbumResponse], tags=["albums"])
def read_albums(db: Session = Depends(get_db)):
    # Retrieves a list of albums

The OpenAPI Specification (OAS) is a standardized format for describing RESTful APIs, defining their endpoints, request/response structures, and other metadata in a machine-readable way to ease API integration and documentation. FastAPI generates an OAS API specification by default that can be easily downloaded on the API interactive documentation page.

But for our purposes, we want to have a script called generate_openapi_schema.py to export the OpenAPI specification to a file named openapi.json. This file is required to generate our frontend service client in the next steps.

Check the script code below:

import json
from pathlib import Path
from main import app
import os


OUTPUT_FILE = os.getenv("OPENAPI_OUTPUT_FILE")

def generate_openapi_schema(output_file):
    schema = app.openapi()
    output_path = Path(output_file)
    output_path.write_text(json.dumps(updated_schema, indent=2))
    print(f"OpenAPI schema saved to {output_file}")

if __name__ == "__main__":
    generate_openapi_schema(OUTPUT_FILE)

The generate_openapi_schema function creates an OpenAPI schema from a FastAPI app and saves it to the specified output file.

We can easily call this script, and the file will be saved in the directory specified by the OPENAPI_OUTPUT_FILE env var:

> OPENAPI_OUTPUT_FILE= python -m commands.generate_openapi_schema

Generate Frontend Client Using openapi-ts

The next step is to use the openapi.json schema file as an input to generate the frontend client. We can now easily update our frontend project to configure and use the @hey-api/openapi-ts package. This is a tool that generates TypeScript clients from an OpenAPI schema, providing typed API functions for easier integration in frontend projects.

Install @hey-api/openapi-ts and create the openapi-ts.config.ts to configure it:

> npm install @hey-api/openapi-ts

// openapi-ts.config.ts

import { defineConfig } from '@hey-api/openapi-ts';


const openapiFile = process.env.OPENAPI_OUTPUT_FILE

export default defineConfig({
  client: '@hey-api/client-fetch',
  input: openapiFile as string,
  output: 'app/openapi-client',
});

  1. The defineConfig function sets up the configuration for generating a TypeScript client;
  1. It uses the OpenAPI JSON file as input, specified by the OPENAPI_OUTPUT_FILE environment variable;
  2. The generated client code is output to the app/openapi-client directory;
  3. It specifies @hey-api/client-fetch as the client type to facilitate fetch-based API calls.

Generate the frontend service client:

‍> OPENAPI_OUTPUT_FILE= npx openapi-ts

As an output, you have a new folder openapi-client inside your frontend app, containing three new files:

app
  | openapi-client
    | schemas.gen.ts: Contains the shapes of the data models used in the API requests and responses.
    | types.gen.ts: Includes type definitions to ensure type safety throughout the application.
    | services.gen.ts: Provides functions to each API endpoint, with proper typing for request and response data.
s

You can now easily integrate the generated functions and types with your frontend component:

"use server";

import {ReadAlbumsResponse, readAlbums} from "@/app/openapi-client";

export async function fetchAlbums(): Promise<ReadAlbumsResponse | undefined> {
    try {
        const response = await readAlbums();
        return response.data;
    } catch (error) {
        throw new Error("Failed to fetch albums");
    }
}

Automate OpenAPI Schema Generation

With the ease of generating a typed frontend client, we still need to execute commands manually to ensure everything stays in sync. Whenever we add new fields to the API, we must remember to update both the OpenAPI specification and the frontend client, which could lead to deployment issues affecting users.

Using a monorepo allows us to see and manage all our code in one spot, making it easier to communicate between projects and keep everything up to date, so we can quickly tackle any changes and really improve our Developer Experience (DevEx). This setup makes it simple to automate with watchers that detect API changes, automatically regenerate the OpenAPI spec and client, and minimize the need for manual intervention.

We are using one watcher script to update the openapi.json schema every time the API endpoints change with the watchdog library. Additionally, we are incorporating mypy checks, to enhance type safety and help catch potential issues early, ensuring our code remains reliable.

Install watchdog:

> pip install watchdog mypy

Add a configuration mypy.ini file into the backend root:

[mypy]
python_version = 3.12
files = **/*.py
ignore_missing_imports = True
strict = True

Add the script below:

import time
import re
import subprocess
import os
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from threading import Timer

WATCHER_REGEX_PATTERN = re.compile(r"(main|schemas)\.py$")
APP_PATH = "app"

class MyHandler(FileSystemEventHandler):
    def __init__(self):
        super().__init__()
        self.debounce_timer = None
        self.last_modified = 0

    def on_modified(self, event):
        if not event.is_directory and WATCHER_REGEX_PATTERN.search(os.path.basename(event.src_path)):
            current_time = time.time()
            if current_time - self.last_modified > 1:
                self.last_modified = current_time
                if self.debounce_timer:
                    self.debounce_timer.cancel()
                self.debounce_timer = Timer(1.0, self.execute_command, [event.src_path])
                self.debounce_timer.start()

    def execute_command(self, file_path):
        print(f"File {file_path} has been modified and saved.")
        self.run_mypy_checks()
        self.run_openapi_schema_generation()

    def run_mypy_checks(self):
        """Run mypy type checks and print output."""
        print("Running mypy type checks...")
        result = subprocess.run(["poetry", "run", "mypy", "app"], capture_output=True, text=True, check=False)
        print(result.stdout, result.stderr, sep="\n")
        print("Type errors detected! We recommend checking the mypy output for "
              "more information on the issues." if result.returncode else "No type errors detected.")

    def run_openapi_schema_generation(self):
        """Run the OpenAPI schema generation command."""
        print("Proceeding with OpenAPI schema generation...")
        try:
            subprocess.run(["poetry", "run", "python", "-m", "commands.generate_openapi_schema"], check=True)
            print("OpenAPI schema generation completed successfully.")
        except subprocess.CalledProcessError as e:
            print(f"An error occurred while generating OpenAPI schema: {e}")

if __name__ == "__main__":
    observer = Observer()
    observer.schedule(MyHandler(), APP_PATH, recursive=True)
    observer.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()
  1. The root path of the application is set to app using the constant APP_PATH.
  2. The on_modified function checks for changes in files matching the regex pattern (main.py or schemas.py) defined by WATCHER_REGEX_PATTERN.
  3. It ensures the command is executed only after confirming file modification, using a debounce mechanism to prevent rapid executions.
  4. The execute_command function generates the OpenAPI schema with the command: poetry run python -m commands.generate_openapi_schema.
  5. The run_mypy_checks function performs type checks in the app directory using mypy, displaying any output or errors.
  6. If type errors are detected, a warning advises checking the mypy output, but the OpenAPI schema generation continues, promoting continuous development while encouraging type safety.

I suggest you run the watcher.py file every time you’re running your application during development time. In order to manage that, you can create a very simple start.sh script that executes both the development server and the watcher.

#!/bin/bash

fastapi dev main.py --host 0.0.0.0 --port 8000 --reload & python watcher.py

wait

Now, every time you make a change to either the schema.py or the main.py file, you should see the message on the console:

File {file_path} has been modified and saved.

Automate Client Generation

We need to do the same for the frontend as we did for the backend: create a frontend watcher, and execute it once the frontend server is up and running. The frontend watcher will be listening for changes to the openapi.json schema file generated by the backend.

Let’s start by installing chokidar, a popular JavaScript library to watch files.

> npm install chokidar

Add the script below:

// watcher.js
const chokidar = require('chokidar');
const { exec } = require('child_process');


const openapiFile = process.env.OPENAPI_OUTPUT_FILE
// Watch the specific file for changes
chokidar.watch(openapiFile).on('change', (path) => {
  console.log(`File ${path} has been modified. Running generate-client...`);
  exec('npm run generate-client', (error, stdout, stderr) => {
    if (error) {
      console.error(`Error: ${error.message}`);
      return;
    }
    if (stderr) {
      console.error(`stderr: ${stderr}`);
      return;
    }
    console.log(`stdout: ${stdout}`);
  });
});
  1. We use the environment variable OPENAPI_OUTPUT_FILE to specify the name of the file to watch for changes;
  2. Once there's a change to this specific file, we execute the command npm run generate-client;
  3. When a change is detected, a message is logged to the console indicating that the file has been modified;
  4. If there's an error during command execution, it is logged to the console.

Create the start.sh file (don’t forget to make it executable):

#!/bin/bash

npm run dev & node watcher.js

wait

The final touch

To see TypeScript errors in the frontend console whenever a new client is generated, especially if frontend components are still using outdated types and functions, you need to install and use fork-ts-checker-webpack-plugin. This tool is responsible for displaying real-time TypeScript errors in the console during development. Adding the plugin enables a fast feedback loop that helps us catch issues before our users do, ensuring that the frontend components integrate smoothly with the backend API.

Install fork-ts-checker-webpack-plugin:

> npm install fork-ts-checker-webpack-plugin

Update the next.config.mjs, you can adapt your own configuration file to reflect these changes:

import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.plugins.push(
        new ForkTsCheckerWebpackPlugin({
          async: true,
          typescript: {
            configOverwrite: {
              compilerOptions: {
                skipLibCheck: true,
              },
            },
          },
        })
      );
    }
    return config;
  },
};
  1. Adds ForkTsCheckerWebpackPlugin to the Webpack plugins array.
  2. Setting async: true allows TypeScript checks to run without blocking the build process. Other build processes continue while type checking occurs in the background.
  3. TypeScript errors are reported in the console without halting the build, improving overall performance and developer workflow.

You now have the complete configuration needed to catch errors in our application. Congratulations!

Add commands to pre-commit Hook

Here’s a bonus for you! Imagine if you forgot to run the watchers and made changes to the API. To further protect your code's quality, you can set up a pre-commit hook — a script that runs automatically before you commit changes—to check for changes in your API.

Since we're using a monorepo, we can easily verify both the frontend and backend in the same pre-commit and execute the necessary commands. If any changes are detected, it will automatically generate a new schema and new clients. How cool is that?

- repo: local
  hooks:
    - id: generate-openapi-schema
      name: generate OpenAPI schema
      entry: sh -c 'cd backend && python -m commands.generate_openapi_schema'
      language: system
      pass_filenames: false
    - id: generate-frontend-client
      name: generate frontend client
      entry: sh -c 'cd frontend && npm run generate-client'
      language: system
      # Only run frontend client generation if frontend files have changed:
      files: openapi\.json$
      pass_filenames: false

Conclusion

By leveraging OpenAPI specifications and implementing watchers for both the backend and frontend, we can ensure that our applications stay in sync, providing rapid feedback and reducing the risk of errors. The solutions we’ve discussed not only streamline the development process but also promote a more robust, maintainable codebase. 

We are excited to announce that we will soon release a new boilerplate for Next.js and FastAPI. This boilerplate will incorporate the configurations and practices discussed in this post, enabling developers to hit the ground running with a solid foundation for their projects.

Stay tuned for our upcoming release, and get ready to elevate your development workflow!