Overview
Unlock fully type-safe API calls in Next.js 15. This guide to openapi-typescript-codegen shows how to auto-generate a client, boosting productivity and shipping faster.

You push the feature. It passed all your local tests. The PR is approved, CI is green, and it's deployed to production. Ten minutes later, the first Sentry alert hits your inbox. Then another. And another. The app is crashing for a segment of users, and the error is maddeningly simple:
I've been there. We've all been there. After a frantic debugging session, you find the culprit. The backend team had deployed a "non-breaking" change, renaming a field from
What if you could eliminate this entire class of bugs? What if your editor could tell you the exact moment an API contract changed? What if your data-fetching layer was always, automatically, 100% in sync with your backend?
This isn't a fantasy. It's the reality of OpenAPI-driven development. By leveraging
What You'll Achieve with This Guide
This isn't just another tutorial that shows you how to run a single command. We're going deep. By the end of this comprehensive guide, you will:
- Automate Your API Client: Generate a complete TypeScript client from an OpenAPI schema with a single command.
- Achieve End-to-End Type Safety: Ensure your data types are consistent from the backend database all the way to your React components.
- Master Modern Data Fetching: Implement robust data fetching patterns in Next.js 15 using the App Router and React Server Components (RSCs).
- Build a Production-Ready Axios Instance: Go beyond the basics by creating a reusable Axios client with interceptors for authentication and global error handling.
- Boost Your Development Workflow: Set up automated regeneration so your API client is always up-to-date with zero manual effort.
Let's kill the silent productivity killer and build a more resilient, efficient, and enjoyable development experience.
Prerequisites
Before we dive in, let's make sure you have everything you need to follow along. This guide is aimed at intermediate to advanced developers, so we'll move quickly through the basics.
What You'll Need
- Node.js: v18 or later.
- A Next.js 15 Project: If you're starting from scratch, run npx create-next-app@latestand follow the prompts.
- TypeScript & Async/Await: A solid understanding of modern JavaScript/TypeScript is essential.
- An OpenAPI 3.0 Specification: You'll need an openapi.jsonoropenapi.yamlfile that describes your API. If you don't have one, don't worry! You can use the classic Petstore OpenAPI specification to follow along with the examples.
Part 1: Why OpenAPI-Driven Development is a Game-Changer
Before we write a single line of code, it's crucial to understand the "why." Why go through the trouble of setting up code generation when you could just write
What is openapi-typescript-codegen?
At its core,
Think of it as a contract. Your backend team provides an OpenAPI schema, which is a legally binding document describing every endpoint, its parameters, its request body, and its possible responses.
The Core Benefits of Code Generation
Adopting this workflow isn't just about adding a cool new tool to your stack. It fundamentally changes how you build and maintain your application's data layer.
- ✅ Guaranteed Type Safety: This is the headline feature. When the backend changes an endpoint, your TypeScript compiler will immediately throw an error if your code is no longer compatible. The authorNametoauthor_namebug? It would have been caught before you even saved the file.
- ✅ Eliminates Manual Boilerplate: Stop writing the same fetchoraxiosboilerplate over and over again. The generated client handles request creation, parameter serialization, and response parsing for you, letting you focus on your application logic.
- ✅ Single Source of Truth: The OpenAPI schema becomes the undisputed source of truth for your API contract. There are no more arguments, no more "that's not what the documentation says," no more digging through backend code. If it's in the schema, it's in the client.
- ✅ Faster Development Cycles: Autocomplete becomes your superpower. When you type ApiService., your editor will show you every available method. When you call a method, you'll get hints for every parameter. When you get a response, you'll know every property on the object. This drastically reduces context switching and trips to the API documentation.
In short, you're trading a small, one-time setup cost for massive long-term gains in productivity, stability, and developer experience.
Part 2: The Setup - Your First Generated Client in 5 Minutes
Enough theory. Let's get our hands dirty and see just how easy it is to get started. We'll go from zero to a fully generated API client in just a few steps.
Step 1: Installing the Dependency
First, we need to add
Open your terminal in your Next.js project root and run one of the following commands:
bash# Using npm npm install openapi-typescript-codegen --save-dev # Using yarn yarn add openapi-typescript-codegen --dev # Using pnpm pnpm add openapi-typescript-codegen -D
Step 2: Adding Your OpenAPI Schema
Next, you need a place to store your API specification file. I recommend creating a dedicated directory for this at the root of your project to keep things organized.
- Create a directory named schemain your project's root.
- Inside schema, place your OpenAPI file. Let's call itopenapi.yaml.
If you're using the Petstore example, download it and save it to
my-nextjs-app/ ├── schema/ │ └── openapi.yaml ├── src/ │ └── app/ ├── package.json └── ...
Step 3: Creating the Generation Script
Now, let's create a reusable script in our
Open your
json// package.json { "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "gen:api": "openapi --input ./schema/openapi.yaml --output ./src/lib/api --client axios" } }
Let's break down that
- openapi: This is the CLI command provided by the package we installed.
- --input ./schema/openapi.yaml: This flag tells the generator where to find the source OpenAPI specification file.
- --output ./src/lib/api: This flag specifies the directory where the generated TypeScript files will be placed. I like to keep generated code insrc/liborsrc/shared.
- --client axios: This is a crucial flag. It tells the generator to create service methods that use the Axios HTTP client for making requests. You can also choosefetchornode, but for this guide, we're focusing on Axios to build a more robust client.
Step 4: Running the Script and Understanding the Output
With everything in place, it's time for the magic. Run the script from your terminal:
bashnpm run gen:api
You should see some output in your console indicating success. Now, let's look at what it created inside
src/lib/api/ ├── core/ │ ├── ApiError.ts │ ├── ApiRequestOptions.ts │ ├── AxiosHttpRequest.ts │ ├── BaseHttpRequest.ts │ ├── CancelablePromise.ts │ ├── OpenAPI.ts │ └── request.ts ├── index.ts ├── models/ │ ├── Order.ts │ ├── Pet.ts │ └── ... (many more model files) └── services/ ├── PetsService.ts ├── StoreService.ts └── UsersService.ts
This might look intimidating, but it's very logically structured:
- index.ts: The main entry point that exports everything from the other files for easy importing.
- core/: The engine of your API client. You'll rarely need to touch these files, but they handle the underlying request logic, error handling, and configuration.
- models/: Contains all the TypeScripttypeandinterfacedefinitions derived from your schema'scomponents.schemassection. This is where types likePetorUserlive.
- services/: This is where you'll spend most of your time. Each file corresponds to a tag in your OpenAPI spec (e.g., "pets", "store") and contains a class with static methods for each endpoint. For example,PetsService.listPets()orUsersService.createUser().
Congratulations! You now have a fully-typed API client ready to use.
Step 5: Your First API Call (The Naive Approach)
Let's make our first API call to see this in action. The generated client makes it incredibly simple. Here's how you might call it in a basic React Server Component:
tsx// app/pets/page.tsx import { PetsService } from '@/lib/api'; export default async function PetsPage() { const pets = await PetsService.listPets(); return ( <div> <h1>Our Pets</h1> <ul> {pets.map((pet) => ( <li key={pet.id}> {pet.name} - {pet.status} </li> ))} </ul> </div> ); }
Notice something magical? Full autocomplete. Your editor knows that
But this is the naive approach. In production, you need more control—authentication, error handling, base URLs, and more. That's what we'll build next.
Part 3: Building a Production-Ready Axios Instance
The generated client works out of the box, but it's not production-ready yet. We need to configure it properly with authentication, error handling, and environment-specific settings. This is where the real power of Axios shines.
Step 1: Install Axios
If you haven't already, install Axios:
bashnpm install axios
Step 2: Create a Custom Axios Instance
Let's create a centralized Axios instance with all the production-ready features we need. Create a new file at
tsx// src/lib/axios-instance.ts import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; // Create the base Axios instance export const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com', timeout: 30000, // 30 seconds headers: { 'Content-Type': 'application/json', }, }); // Request interceptor - adds auth token to every request axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Get token from wherever you store it (cookies, localStorage, etc.) const token = getAuthToken(); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error: AxiosError) => { return Promise.reject(error); } ); // Response interceptor - global error handling axiosInstance.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config; // Handle 401 - Unauthorized (token expired) if (error.response?.status === 401 && originalRequest) { // Try to refresh the token try { const newToken = await refreshAuthToken(); if (newToken && originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken}`; return axiosInstance(originalRequest); } } catch (refreshError) { // Refresh failed, redirect to login redirectToLogin(); return Promise.reject(refreshError); } } // Handle other errors globally if (error.response?.status === 403) { console.error('Forbidden: You do not have permission'); } if (error.response?.status === 500) { console.error('Server error occurred'); } return Promise.reject(error); } ); // Helper functions (implement based on your auth strategy) function getAuthToken(): string | null { // Example: return cookies.get('auth_token'); if (typeof window !== 'undefined') { return localStorage.getItem('auth_token'); } return null; } async function refreshAuthToken(): Promise<string | null> { // Implement your token refresh logic // Example: call your /auth/refresh endpoint return null; } function redirectToLogin(): void { if (typeof window !== 'undefined') { window.location.href = '/login'; } }
Step 3: Configure the Generated Client to Use Your Axios Instance
Now we need to tell the generated client to use our custom Axios instance. The generated code has a configuration file at
tsx// src/lib/api/core/OpenAPI.ts import type { ApiRequestOptions } from './ApiRequestOptions'; type Resolver<T> = (options: ApiRequestOptions) => Promise<T>; type Headers = Record<string, string>; export type OpenAPIConfig = { BASE: string; VERSION: string; WITH_CREDENTIALS: boolean; CREDENTIALS: 'include' | 'omit' | 'same-origin'; TOKEN?: string | Resolver<string> | undefined; USERNAME?: string | Resolver<string> | undefined; PASSWORD?: string | Resolver<string> | undefined; HEADERS?: Headers | Resolver<Headers> | undefined; ENCODE_PATH?: ((path: string) => string) | undefined; }; export const OpenAPI: OpenAPIConfig = { BASE: '', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', TOKEN: undefined, USERNAME: undefined, PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, };
But here's the problem: if you modify this file directly, it will be overwritten the next time you run
Create
tsx// src/lib/api-config.ts import { OpenAPI } from './api'; import { axiosInstance } from './axios-instance'; // Configure the generated OpenAPI client export function configureApiClient() { // Set the base URL (this will be used if not using custom axios instance) OpenAPI.BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com'; // If you want to use custom headers globally OpenAPI.HEADERS = async () => ({ 'X-Client-Version': '1.0.0', }); // For token-based auth (alternative to interceptors) // OpenAPI.TOKEN = async () => { // return getAuthToken() || ''; // }; } // Export configured axios instance for direct use if needed export { axiosInstance };
However, there's a more elegant solution. We can directly modify the generated
Create
tsx// src/lib/api-client.ts import { OpenAPI } from './api'; import { axiosInstance } from './axios-instance'; // Configure the OpenAPI client with our custom axios instance OpenAPI.BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com'; // Export the configured axios instance export { axiosInstance }; // Re-export all services for convenience export * from './api/services'; export * from './api/models';
Now, import your services from
Step 4: Environment Variables
Don't forget to add your API URL to your environment variables. Create or update
bash# .env.local NEXT_PUBLIC_API_URL=https://api.example.com
For production, you'll set this in your deployment platform (Vercel, Netlify, etc.).
Part 4: Using the Client in Next.js 15 with App Router
Now that we have a bulletproof API client, let's explore how to use it effectively in Next.js 15's App Router with both Server Components and Client Components.
Server Components (Default in App Router)
Server Components are the default in Next.js 15's App Router. They run on the server, which means:
- No JavaScript is sent to the client for these components
- You can directly access databases, file systems, and APIs
- Perfect for data fetching
Here's a production-ready example:
tsx// app/products/page.tsx import { ProductsService, type Product } from '@/lib/api-client'; import { Suspense } from 'react'; export default function ProductsPage() { return ( <div className="container mx-auto py-8"> <h1 className="text-3xl font-bold mb-6">Our Products</h1> <Suspense fallback={<ProductsSkeleton />}> <ProductsList /> </Suspense> </div> ); } async function ProductsList() { try { const products = await ProductsService.listProducts({ limit: 50, status: 'active', }); if (products.length === 0) { return <p className="text-gray-500">No products found.</p>; } return ( <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div> ); } catch (error) { return ( <div className="text-red-500"> Failed to load products. Please try again later. </div> ); } } function ProductCard({ product }: { product: Product }) { return ( <div className="border rounded-lg p-4"> <h3 className="font-semibold">{product.name}</h3> <p className="text-gray-600">${product.price}</p> <p className="text-sm text-gray-500 mt-2">{product.description}</p> </div> ); } function ProductsSkeleton() { return ( <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> {[...Array(6)].map((_, i) => ( <div key={i} className="border rounded-lg p-4 animate-pulse"> <div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div> <div className="h-4 bg-gray-200 rounded w-1/2"></div> </div> ))} </div> ); }
Client Components (With 'use client')
For interactive features, forms, and mutations, you'll use Client Components:
tsx// app/products/create/page.tsx 'use client'; import { useState } from 'react'; import { ProductsService, type CreateProductRequest } from '@/lib/api-client'; import { useRouter } from 'next/navigation'; export default function CreateProductPage() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setIsLoading(true); setError(null); const formData = new FormData(e.currentTarget); const productData: CreateProductRequest = { name: formData.get('name') as string, price: Number(formData.get('price')), description: formData.get('description') as string, category: formData.get('category') as string, }; try { await ProductsService.createProduct(productData); router.push('/products'); router.refresh(); // Refresh server components } catch (err) { setError('Failed to create product. Please try again.'); console.error(err); } finally { setIsLoading(false); } } return ( <div className="container mx-auto py-8 max-w-md"> <h1 className="text-3xl font-bold mb-6">Create New Product</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="name" className="block text-sm font-medium mb-1"> Product Name </label> <input id="name" name="name" type="text" required className="w-full border rounded-lg px-3 py-2" /> </div> <div> <label htmlFor="price" className="block text-sm font-medium mb-1"> Price </label> <input id="price" name="price" type="number" step="0.01" required className="w-full border rounded-lg px-3 py-2" /> </div> <div> <label htmlFor="category" className="block text-sm font-medium mb-1"> Category </label> <select id="category" name="category" required className="w-full border rounded-lg px-3 py-2" > <option value="electronics">Electronics</option> <option value="clothing">Clothing</option> <option value="books">Books</option> </select> </div> <div> <label htmlFor="description" className="block text-sm font-medium mb-1"> Description </label> <textarea id="description" name="description" rows={4} className="w-full border rounded-lg px-3 py-2" /> </div> {error && ( <div className="bg-red-50 border border-red-200 text-red-600 px-4 py-2 rounded"> {error} </div> )} <button type="submit" disabled={isLoading} className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50" > {isLoading ? 'Creating...' : 'Create Product'} </button> </form> </div> ); }
Server Actions (The Modern Way)
Next.js 15's Server Actions provide the best of both worlds. Create
tsx// app/actions/products.ts 'use server'; import { ProductsService, type CreateProductRequest } from '@/lib/api-client'; import { revalidatePath } from 'next/cache'; export async function createProduct(formData: FormData) { const productData: CreateProductRequest = { name: formData.get('name') as string, price: Number(formData.get('price')), description: formData.get('description') as string, category: formData.get('category') as string, }; try { const product = await ProductsService.createProduct(productData); revalidatePath('/products'); return { success: true, product }; } catch (error) { return { success: false, error: 'Failed to create product' }; } } export async function deleteProduct(productId: string) { try { await ProductsService.deleteProduct({ id: productId }); revalidatePath('/products'); return { success: true }; } catch (error) { return { success: false, error: 'Failed to delete product' }; } }
Then use it in your component:
tsx// app/products/create-form.tsx 'use client'; import { useFormStatus } from 'react-dom'; import { createProduct } from '@/app/actions/products'; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending} className="w-full bg-blue-600 text-white py-2 rounded-lg" > {pending ? 'Creating...' : 'Create Product'} </button> ); } export function CreateProductForm() { return ( <form action={createProduct} className="space-y-4"> {/* Form fields here */} <SubmitButton /> </form> ); }
Part 5: Automating the Workflow
The final piece of the puzzle is automation. Your API schema will change frequently, and manually running
Option 1: Pre-commit Hook with Husky
Install Husky and lint-staged:
bashnpm install --save-dev husky lint-staged npx husky install
Add a pre-commit hook:
bashnpx husky add .husky/pre-commit "npm run gen:api && git add src/lib/api"
Now, every time you commit, the API client regenerates if the schema changed.
Option 2: File Watcher During Development
For local development, you can watch the schema file and auto-regenerate. Install
bashnpm install --save-dev nodemon
Add a script to
json{ "scripts": { "watch:api": "nodemon --watch schema --ext yaml,json --exec npm run gen:api" } }
Run
Option 3: CI/CD Integration
In your GitHub Actions workflow:
yaml# .github/workflows/type-check.yml name: Type Check on: [pull_request] jobs: type-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '18' - run: npm ci - run: npm run gen:api - run: npm run type-check
This ensures the generated types are always fresh and type-safe.
Advanced Tips and Best Practices
1. Handling Different Environments
tsx// src/lib/api-config.ts const API_URLS = { development: 'http://localhost:3001', staging: 'https://staging-api.example.com', production: 'https://api.example.com', }; const environment = process.env.NODE_ENV as keyof typeof API_URLS; OpenAPI.BASE = API_URLS[environment] || API_URLS.development;
2. Custom Error Handling
tsx// src/lib/api-error-handler.ts import { ApiError } from '@/lib/api'; export function handleApiError(error: unknown) { if (error instanceof ApiError) { switch (error.status) { case 400: return 'Invalid request. Please check your input.'; case 401: return 'Please log in to continue.'; case 403: return 'You do not have permission to perform this action.'; case 404: return 'The requested resource was not found.'; case 500: return 'Server error. Please try again later.'; default: return 'An unexpected error occurred.'; } } return 'An unexpected error occurred.'; }
3. Request Cancellation
tsx// Using the built-in CancelablePromise import { PetsService } from '@/lib/api-client'; const request = PetsService.listPets(); // Cancel if component unmounts useEffect(() => { return () => { request.cancel(); }; }, []);
4. Custom Types Extension
If you need to add custom properties to generated types:
tsx// src/types/api-extensions.ts import type { User } from '@/lib/api'; export interface ExtendedUser extends User { fullName: string; avatarUrl: string; }
Conclusion: You've Built Something Special
Congratulations! You've just built a production-grade, type-safe API client that will save you countless hours of debugging and make your codebase significantly more maintainable.
What you've accomplished:
✅ Eliminated an entire class of API-related bugs
✅ Created a seamless developer experience with full autocomplete
✅ Built robust error handling and authentication
✅ Integrated perfectly with Next.js 15's App Router
✅ Automated your workflow for zero-friction updates
The beauty of this setup is that it scales. Whether you're building a side project or a multi-million dollar application, the patterns you've learned here will serve you well.
Next steps:
- Explore adding request/response transformers for data normalization
- Implement retry logic for failed requests
- Add request deduplication to prevent redundant API calls
- Set up mock servers using your OpenAPI schema for testing
The silent killer of frontend productivity is dead. Long live type safety.
Resources
- OpenAPI Specification
- openapi-typescript-codegen Documentation
- Next.js 15 Documentation
- Axios Documentation
Happy coding! 🚀
Daniel Olawoyin
Full-Stack Developer with expertise in React, Next.js, and modern web technologies. Passionate about creating exceptional digital experiences.
Enjoyed this article?
Subscribe to get notified when new articles are published, or explore more content on our blog.