You're integrating a third-party API. The docs show a POST /users endpoint. You write this:
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: 'John',
emial: '[email protected]', // typo: "emial"
}),
})That typo compiles. TypeScript sees body as string and doesn't care. You deploy. Production breaks. The API returns 400 Bad Request and your users see "Something went wrong."
This is the cost of hand-written API clients: no compile-time safety, no autocomplete, no contract enforcement.
Why We Keep Doing This
Most APIs publish an OpenAPI spec. It's a machine-readable contract that defines every endpoint, parameter, and response type. But we ignore it and write fetch calls anyway because:
- OpenAPI feels like enterprise bloat — XML vibes, verbose JSON, scary tooling
- Generated code has a bad reputation — Java-style class hierarchies, unreadable output
- "It's just one endpoint" — until it's 47 endpoints and the backend team ships breaking changes
The thing is, modern generators have fixed these problems. The output in 2026 looks like code you'd write yourself. Clean, tree-shakeable, readable.
Picking a Generator
Not all generators are equal. Here's what matters:
| Library | Runtime | Bundle Size | Standout Feature | Best For |
|---|---|---|---|---|
| @hey-api/openapi-ts | Fetch | ~2KB | Modern output, plugin system | New projects |
| openapi-fetch | Fetch | ~1KB | Types only, zero runtime | Minimal bundle |
| orval | Axios/Fetch | ~3KB | React Query hooks, Zod schemas | React apps |
| openapi-typescript-codegen | Fetch/Axios | ~2KB | Mature, battle-tested | General purpose |
| openapi-generator | Any | Varies | 50+ language targets | Polyglot teams |
We'll use @hey-api/openapi-ts. It produces the cleanest output and handles modern TypeScript well.
How It Works
The flow is simple:
Your API publishes a spec. The generator reads it. You get types. TypeScript catches mistakes before they hit production.
No magic. Just code generation.
Generate the Client
Install it:
pnpm add -D @hey-api/openapi-ts @hey-api/client-fetchPoint it at your OpenAPI spec. URL or local file, doesn't matter:
npx @hey-api/openapi-ts \
-i https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/doc \
-o src/lib/api \
-c @hey-api/client-fetchYou get a src/lib/api/ folder:
src/lib/api/
├── index.ts # Re-exports everything
├── client.ts # Configured fetch client
├── types.gen.ts # All request/response types
└── services.gen.ts # Typed endpoint functionsConfigure the Client
Set up the base URL and auth:
// src/lib/api/client.ts
import { createClient } from '@hey-api/client-fetch'
export const apiClient = createClient({
baseUrl: import.meta.env.VITE_API_URL,
headers: {
Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
},
})Use It
Now your API calls are typed:
import { htmlToPdf } from '@/lib/api'
import { apiClient } from '@/lib/api/client'
const { data, error } = await htmlToPdf({
client: apiClient,
body: {
html: '<h1>Invoice #1234</h1>',
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.5,
// emial: 'typo' ← TypeScript error: 'emial' does not exist
},
})
if (error) {
// error is typed: 400 | 401 | 402 | 500
console.error(error.message)
return
}
// data is typed: Blob (PDF binary)
const url = URL.createObjectURL(data)That typo from the beginning? Caught at compile time. Your IDE shows autocomplete for every field. Hover over paperWidth and you see the description from the spec.
Automate It
Your SDK should update when the API changes. Add a script:
{
"scripts": {
"generate:api": "openapi-ts -i https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/doc -o src/lib/api -c @hey-api/client-fetch"
}
}Then set up a daily CI job:
# .github/workflows/sync-api.yml
name: Sync API Client
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- run: pnpm install
- run: pnpm generate:api
- name: Check for changes
run: |
if [[ -n $(git status --porcelain src/lib/api) ]]; then
echo "API client changed"
git add src/lib/api
git commit -m "chore: regenerate API client"
git push
fiBreaking changes in the API spec now show up as TypeScript errors in your next build. No more surprises in production.
How We Handle This at NeatPDF
We publish our OpenAPI spec at:
https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/docEvery endpoint, parameter, and error code is in there. When we add features or deprecate fields, the spec updates automatically.
We also have an official SDK on npm:
pnpm add @neatpdf/sdkBut honestly? We encourage teams to generate their own. Here's why:
- You control the output — match your codebase conventions
- You control when to update — regenerate on your schedule, not ours
- You're not locked in — the spec works with any generator, any language
The spec is the contract. The SDK is just a convenience.
Hand-written API clients are technical debt disguised as simplicity. They skip validation, drift from the actual API, and turn typos into production incidents.
The generators have gotten good. The excuses haven't.
If your API doesn't publish an OpenAPI spec, ask them to. If you're building an API, publish one.