DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Stop Writing API Clients by Hand: Generate Type-Safe SDKs from OpenAPI in 2026

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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,
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode
npx @hey-api/openapi-ts
Enter fullscreen mode Exit fullscreen mode

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' },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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' });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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 },
  });
}
Enter fullscreen mode Exit fullscreen mode
# Generate spec from JSDoc, then generate client types
npx next-openapi-gen generate --output api/openapi.yaml
npm run generate:api
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

$99 → whoffagents.com


Top comments (0)