Stop Writing API Clients by Hand: Generate Type-Safe SDKs from OpenAPI in 2026
If you're writing TypeScript fetch wrappers by hand in 2026, you're creating maintenance debt with every endpoint. The OpenAPI tooling has matured enough that generated clients are cleaner than hand-written ones, stay in sync with your API automatically, and produce types that are more accurate than what most developers write manually.
This article covers two approaches: the openapi-typescript + openapi-fetch combo for lightweight fetch-based clients, and hey-api/openapi-ts for full SDK generation with organized service namespaces. Real worked example: generating a Stripe-like client from your own Next.js API.
Why Generated Clients Beat Hand-Written Wrappers
Hand-written API clients have three systematic failure modes:
Type drift — the backend renames a field from createdAt to created_at and you don't update the client type until an integration test fails or, worse, production breaks. The type lies about the actual API response.
Incomplete coverage — developers add typed wrappers for the endpoints they're currently working on and leave the rest as fetch(url).then(r => r.json()) with any types. The pattern is inconsistent across the codebase.
Maintenance tax — every new endpoint needs someone to write a typed wrapper, update any shared interface files, and remember to export the new function. It takes time and gets skipped under deadline pressure.
Generated clients solve all three: your OpenAPI spec is the source of truth, every endpoint is typed, and regeneration is a single command. The spec drift problem inverts — now the client is always correct, and it's the backend code that needs to stay in sync with the spec.
Approach 1: openapi-typescript + openapi-fetch
openapi-typescript generates a TypeScript type file from your spec — pure types, no runtime code, no dependencies. openapi-fetch is a small (~6 KB gzip) fetch wrapper that consumes those types and gives you fully type-checked request/response pairs with path parameter enforcement.
Setup
npm install -D openapi-typescript
npm install openapi-fetch
Generate Types from Your Spec
# From a local YAML file
npx openapi-typescript ./api/openapi.yaml -o ./src/lib/api-types.ts
# From a running dev server (Next.js serves the spec at /api/openapi.json)
npx openapi-typescript http://localhost:3000/api/openapi.json -o ./src/lib/api-types.ts
# With additional strictness — recommended
npx openapi-typescript ./api/openapi.yaml \
--output ./src/lib/api-types.ts \
--immutable \
--empty-objects-unknown
Add to package.json:
{
"scripts": {
"generate:api": "openapi-typescript ./api/openapi.yaml -o ./src/lib/api-types.ts",
"generate:api:remote": "openapi-typescript $API_URL/openapi.json -o ./src/lib/api-types.ts"
}
}
A Realistic API Spec
# api/openapi.yaml
openapi: '3.1.0'
info:
title: Whoff Agents API
version: '1.0.0'
paths:
/api/workspaces:
get:
operationId: listWorkspaces
tags: [Workspaces]
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Workspace' }
meta:
$ref: '#/components/schemas/PaginationMeta'
post:
operationId: createWorkspace
tags: [Workspaces]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateWorkspaceInput' }
responses:
'201':
content:
application/json:
schema: { $ref: '#/components/schemas/Workspace' }
'422':
content:
application/json:
schema: { $ref: '#/components/schemas/ValidationError' }
/api/workspaces/{id}:
get:
operationId: getWorkspace
tags: [Workspaces]
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Workspace' }
'404':
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
components:
schemas:
Workspace:
type: object
required: [id, name, slug, plan, createdAt]
properties:
id: { type: string, format: uuid }
name: { type: string }
slug: { type: string }
plan: { type: string, enum: [free, pro, enterprise] }
createdAt: { type: string, format: date-time }
CreateWorkspaceInput:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
slug: { type: string, pattern: '^[a-z0-9-]+$' }
PaginationMeta:
type: object
required: [total, page, limit]
properties:
total: { type: integer }
page: { type: integer }
limit: { type: integer }
ApiError:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
ValidationError:
type: object
required: [errors]
properties:
errors:
type: object
additionalProperties:
type: array
items: { type: string }
Using the Generated Client
import createClient, { type Middleware } from 'openapi-fetch';
import type { paths } from '@/lib/api-types';
// Create a typed client — all paths, methods, params, and responses are inferred
const apiClient = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? '',
});
// Add auth middleware
const authMiddleware: Middleware = {
async onRequest({ request }) {
const token = getAuthToken(); // your auth implementation
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return request;
},
};
apiClient.use(authMiddleware);
// Fully typed usage
async function listWorkspaces(page = 1) {
const { data, error } = await apiClient.GET('/api/workspaces', {
params: {
query: { page, limit: 20 },
},
});
if (error) {
// TypeScript knows this is ApiError shape
console.error(`API error: ${error.message}`);
return null;
}
// data is typed as { data: Workspace[], meta: PaginationMeta }
return data;
}
async function createWorkspace(name: string, slug?: string) {
const { data, error } = await apiClient.POST('/api/workspaces', {
body: { name, slug }, // TypeScript enforces CreateWorkspaceInput
});
if (error) {
// error can be ValidationError (422) or other errors
return { success: false as const, error };
}
return { success: true as const, workspace: data };
}
async function getWorkspace(id: string) {
const { data, error } = await apiClient.GET('/api/workspaces/{id}', {
params: { path: { id } }, // TypeScript requires the path param
});
return { data, error };
}
If you rename /api/workspaces/{id} to /api/workspaces/{workspaceId} in the spec and regenerate, every call site that uses the old path gets a TypeScript error immediately. The API contract is enforced at compile time.
Approach 2: hey-api/openapi-ts for Full SDK Generation
openapi-fetch gives you typed fetch calls. hey-api/openapi-ts goes further: it generates a complete SDK with service objects organized by OpenAPI tag, Zod schemas, and a configurable client layer. The output looks like a Stripe SDK — service classes with named methods.
npm install -D @hey-api/openapi-ts
npm install @hey-api/client-fetch
// openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
client: '@hey-api/client-fetch',
input: './api/openapi.yaml',
output: {
path: './src/lib/client',
format: 'prettier',
lint: 'eslint',
},
plugins: [
'@hey-api/typescript', // TypeScript interfaces
'@hey-api/sdk', // Service classes with named methods
{
name: '@hey-api/zod', // Zod schemas for runtime validation
exportFromIndex: true,
},
{
name: '@hey-api/transformers', // Transform response dates to Date objects
dates: true,
},
],
});
npx @hey-api/openapi-ts
Generated output in src/lib/client/:
// src/lib/client/sdk.gen.ts (generated — do not edit)
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type {
ListWorkspacesData,
ListWorkspacesResponse,
CreateWorkspaceData,
CreateWorkspaceResponse,
GetWorkspaceData,
GetWorkspaceResponse,
} from './types.gen';
export class WorkspacesService {
public static listWorkspaces(
data: ListWorkspacesData = {}
): CancelablePromise<ListWorkspacesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/workspaces',
query: { page: data.page, limit: data.limit },
});
}
public static createWorkspace(
data: CreateWorkspaceData
): CancelablePromise<CreateWorkspaceResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/workspaces',
body: data.requestBody,
mediaType: 'application/json',
errors: { 422: 'Validation Error' },
});
}
public static getWorkspace(
data: GetWorkspaceData
): CancelablePromise<GetWorkspaceResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/workspaces/{id}',
path: { id: data.id },
errors: { 404: 'Not Found' },
});
}
}
// Usage — Stripe-SDK-style service methods
import { OpenAPI, WorkspacesService } from '@/lib/client';
// Configure once at app startup
OpenAPI.BASE = process.env.NEXT_PUBLIC_API_URL!;
OpenAPI.TOKEN = async () => getAuthToken();
// Named methods organized by resource
const { data: workspaces, meta } = await WorkspacesService.listWorkspaces({ page: 1, limit: 20 });
const newWorkspace = await WorkspacesService.createWorkspace({
requestBody: { name: 'Acme Corp', slug: 'acme-corp' },
});
const workspace = await WorkspacesService.getWorkspace({ id: '550e8400-e29b-41d4-a716' });
Generating the Spec from Your Next.js API
Most teams shouldn't maintain a hand-written spec. Generate it from your route handlers using JSDoc @openapi comments:
npm install -D next-openapi-gen
// src/app/api/workspaces/route.ts
import { NextRequest, NextResponse } from 'next/server';
/**
* @openapi
* /api/workspaces:
* get:
* summary: List workspaces for the authenticated user
* tags: [Workspaces]
* parameters:
* - name: page
* in: query
* schema: { type: integer, default: 1 }
* - name: limit
* in: query
* schema: { type: integer, default: 20, maximum: 100 }
* responses:
* 200:
* description: Paginated workspace list
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: array
* items: { $ref: '#/components/schemas/Workspace' }
* meta: { $ref: '#/components/schemas/PaginationMeta' }
*/
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const page = parseInt(searchParams.get('page') ?? '1');
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20'), 100);
const workspaces = await db.workspace.findMany({
where: { userId: getAuthUserId(req) },
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
const total = await db.workspace.count({ where: { userId: getAuthUserId(req) } });
return NextResponse.json({
data: workspaces,
meta: { total, page, limit },
});
}
# Generate spec from JSDoc, then generate client types
npx next-openapi-gen generate --output api/openapi.yaml
npm run generate:api
Two commands to keep everything in sync. Add both to a generate script:
{
"scripts": {
"generate": "next-openapi-gen generate --output api/openapi.yaml && openapi-typescript ./api/openapi.yaml -o ./src/lib/api-types.ts"
}
}
Enforcing Sync in CI
Generating the types locally is good. Failing the build when they're stale is better:
# .github/workflows/api-types-check.yml
name: API Types Sync
on:
push:
branches: [main]
pull_request:
paths:
- 'src/app/api/**'
- 'api/openapi.yaml'
- 'src/lib/api-types.ts'
jobs:
check-types-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Regenerate API spec from source
run: npx next-openapi-gen generate --output api/openapi.yaml
- name: Regenerate TypeScript types
run: npx openapi-typescript ./api/openapi.yaml -o ./src/lib/api-types.ts
- name: Fail if generated files are stale
run: |
if ! git diff --exit-code api/openapi.yaml src/lib/api-types.ts; then
echo "::error::Generated API files are out of sync."
echo "Run 'npm run generate' and commit the changes."
exit 1
fi
Now any PR that modifies API route handlers must also update the spec and generated types, or CI fails. This makes stale types a build error, not a runtime surprise.
Choosing Between the Two Approaches
Use openapi-typescript + openapi-fetch when:
- You want zero runtime overhead beyond a single tiny fetch wrapper
- Your app already has auth middleware and request infrastructure you want to keep
- You prefer the explicit
client.GET('/path', {...})call pattern over service classes - You're adding types incrementally to a codebase that already makes raw fetch calls
- Bundle size is a constraint (total addition: ~6 KB gzip)
Use hey-api/openapi-ts when:
- You're building the client from scratch and want Stripe-SDK-style service namespaces
- You want Zod schemas generated automatically from your API spec (useful for form validation)
- Your API has many resource types and you want them organized into logical service classes
- You want date string → Date object transformation handled automatically
- You're building an SDK that external developers will consume
Both approaches enforce types at compile time and stay in sync with the spec via code generation. The difference is ergonomics and how much structure you want generated for you.
Stop writing fetch wrappers. The spec is the contract — derive everything from it.
Skip the Boilerplate
The AI SaaS Starter Kit includes a complete OpenAPI pipeline — next-openapi-gen for spec generation, openapi-typescript client types, the CI sync check, and a fully typed apiClient wired into the Next.js app with auth middleware. Ship production-ready in hours.
Top comments (0)