Skip to content
NEXT.JSFeatured

Build Type-Safe APIs in Next.js 15 with OpenAPI Codegen

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.

19 min readBy Daniel Olawoyinopenapi-typescript-codegen · next.js 15 · typescript

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: TypeError: Cannot read properties of undefined (reading 'name').

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 authorName to author_name. Your frontend code, blissfully unaware, was still trying to access the old property. This isn't just a bug; it's a symptom of a deeper problem—a silent killer of frontend productivity and a constant source of friction between teams. It's the slow, painful death by a thousand tiny API mismatches.

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 openapi-typescript-codegen, we can turn our API specification into the ultimate source of truth, generating a fully type-safe client that makes API inconsistencies a relic of the past.

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@latest and 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.json or openapi.yaml file 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 fetch calls manually? The answer lies in scalability and reliability.

What is openapi-typescript-codegen?

At its core, openapi-typescript-codegen is a command-line interface (CLI) tool that reads an OpenAPI v3 specification file and generates a fully-typed TypeScript client. It doesn't just generate type or interface definitions; it generates actual service methods you can call to interact with your API, complete with typed parameters and return values.

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. openapi-typescript-codegen is the lawyer that reads this contract and creates a perfect, easy-to-use toolkit for you, the frontend developer, ensuring you never violate the terms.

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 authorName to author_name bug? It would have been caught before you even saved the file.
  • Eliminates Manual Boilerplate: Stop writing the same fetch or axios boilerplate 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 openapi-typescript-codegen to our project. Since this is a tool we only use during development, we'll install it as a devDependency.

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.

  1. Create a directory named schema in your project's root.
  2. Inside schema, place your OpenAPI file. Let's call it openapi.yaml.

If you're using the Petstore example, download it and save it to ./schema/openapi.yaml. Your project structure should now look something like this:

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 package.json to run the code generator. This makes it easy for you and your teammates to regenerate the client whenever the schema changes.

Open your package.json file and add a new script to the scripts section:

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 gen:api command:

  • 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 in src/lib or src/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 choose fetch or node, 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:

bash
npm 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:

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 TypeScript type and interface definitions derived from your schema's components.schemas section. This is where types like Pet or User live.
  • 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() or UsersService.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 pet has id, name, and status properties. If the backend removes the status field from the API, your TypeScript compiler will immediately flag this line as an error.

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:

bash
npm 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 src/lib/axios-instance.ts:

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 src/lib/api/core/OpenAPI.ts that looks like this:

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 npm run gen:api. Instead, let's create a configuration file that sets everything up properly.

Create src/lib/api-config.ts:

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 AxiosHttpRequest.ts file to use our custom instance. But since that file gets regenerated, let's use a different approach: create a wrapper.

Create src/lib/api-client.ts:

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 @/lib/api-client instead of @/lib/api.

Step 4: Environment Variables

Don't forget to add your API URL to your environment variables. Create or update .env.local:

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 app/actions/products.ts:

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 npm run gen:api gets old fast. Let's set up automatic regeneration.

Option 1: Pre-commit Hook with Husky

Install Husky and lint-staged:

bash
npm install --save-dev husky lint-staged
npx husky install

Add a pre-commit hook:

bash
npx 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 nodemon:

bash
npm install --save-dev nodemon

Add a script to package.json:

json
{
  "scripts": {
    "watch:api": "nodemon --watch schema --ext yaml,json --exec npm run gen:api"
  }
}

Run npm run watch:api in a separate terminal while developing.

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

Happy coding! 🚀

openapi-typescript-codegennext.js 15typescriptopenapiapi clientcode generationtype safetydata fetchingaxiostutorialreact server componentsnext.js app router
Written by
Daniel Olawoyin

Full-stack & AI engineer based in Lagos. I build production systems with AI in them — voice agents, RAG pipelines, multi-tenant SaaS.