Generating API Clients in Monorepos with FastAPI and Next.js
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',
});
- The
defineConfig
function sets up the configuration for generating a TypeScript client;
- It uses the OpenAPI JSON file as input, specified by the
OPENAPI_OUTPUT_FILE
environment variable; - The generated client code is output to the
app/openapi-client
directory; - 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()
- The root path of the application is set to app using the constant
APP_PATH
. - The
on_modified
function checks for changes in files matching the regex pattern (main.py
orschemas.py
) defined byWATCHER_REGEX_PATTERN
. - It ensures the command is executed only after confirming file modification, using a debounce mechanism to prevent rapid executions.
- The
execute_command
function generates the OpenAPI schema with the command:poetry run python -m commands.generate_openapi_schema
. - The
run_mypy_checks
function performs type checks in the app directory usingmypy
, displaying any output or errors. - 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}`);
});
});
- We use the environment variable
OPENAPI_OUTPUT_FILE
to specify the name of the file to watch for changes; - Once there's a change to this specific file, we execute the command npm run
generate-client;
- When a change is detected, a message is logged to the console indicating that the file has been modified;
- 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;
},
};
- Adds
ForkTsCheckerWebpackPlugin
to the Webpack plugins array. - Setting
async: true
allows TypeScript checks to run without blocking the build process. Other build processes continue while type checking occurs in the background. - 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!