openapi-fetch
openapi-fetch
Comprehensive guide for openapi-fetch — a type-safe fetch client that uses OpenAPI schemas for zero-overhead type checking at build time.
When to Apply
Reference these guidelines when:
- Creating API clients from OpenAPI schemas
- Making type-safe HTTP requests (GET, POST, PUT, DELETE, PATCH, etc.)
- Implementing middleware for auth, logging, caching, or error handling
- Configuring query/body/path serialization
- Testing API clients with mocks
- Integrating with React, Vue, Svelte, Next.js, or Nuxt
Installation
npm i openapi-fetch
npm i -D openapi-typescript typescript
Schema Generation
Generate TypeScript types from your OpenAPI schema:
npx openapi-typescript ./path/to/api/v1.yaml -o ./src/lib/api/v1.d.ts
This produces a paths type export used to type the client.
Recommended: Enable noUncheckedIndexedAccess in tsconfig.json for enhanced type safety.
Creating a Client
import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema";
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
});
createClient Options
| Option | Type | Description |
|---|---|---|
baseUrl |
string |
Prefix all fetch URLs |
fetch |
(input: Request) => Promise<Response> |
Custom fetch instance (default: globalThis.fetch) |
Request |
typeof Request |
Custom Request constructor |
querySerializer |
QuerySerializer | QuerySerializerOptions |
Global query param serializer |
bodySerializer |
BodySerializer |
Global body serializer |
pathSerializer |
PathSerializer |
Global path param serializer |
headers |
HeadersOptions |
Default headers for all requests |
requestInitExt |
Record<string, unknown> |
Extension object passed as 2nd argument to fetch |
| (any fetch option) | Any valid RequestInit option (mode, cache, credentials, signal, etc.) |
Making Requests
All HTTP methods are available: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE.
// GET request
const { data, error, response } = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
query: { format: "json" },
},
});
// POST request
const { data, error } = await client.POST("/blogposts", {
body: {
title: "My Post",
content: "Hello world",
},
});
// PUT request
const { data, error } = await client.PUT("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
body: { title: "Updated Title" },
});
// DELETE request
const { data, error } = await client.DELETE("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
Response Structure
Every method returns an object with:
data— Present only on 2xx responses. Typed from the schema's success response.error— Present only on 4xx/5xx responses. Typed from the schema's error response.response— The rawResponseobject with status, headers, etc.
data and error are mutually exclusive — check one to narrow the type:
const { data, error } = await client.GET("/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
if (error) {
// error is typed, data is undefined
console.error(error);
return;
}
// data is typed, error is undefined
console.log(data.title);
Fetch Options Per Request
| Option | Type | Description |
|---|---|---|
params |
{ path?, query?, header?, cookie? } |
Path, query, header, and cookie parameters |
body |
object |
Request body (typed from schema) |
parseAs |
"json" | "text" | "arrayBuffer" | "blob" | "stream" |
Response parsing method (default: "json") |
querySerializer |
QuerySerializer | QuerySerializerOptions |
Per-request query serializer |
bodySerializer |
BodySerializer |
Per-request body serializer |
pathSerializer |
PathSerializer |
Per-request path serializer |
baseUrl |
string |
Override base URL for this request |
fetch |
fetch |
Override fetch for this request |
headers |
HeadersOptions |
Per-request headers (merged with client defaults) |
middleware |
Middleware[] |
Per-request middleware |
| (any fetch option) | Any valid RequestInit option |
Generic request Method
For dynamic HTTP methods:
const { data, error } = await client.request("GET", "/blogposts/{post_id}", {
params: { path: { post_id: "123" } },
});
parseAs Option
Control how the response body is parsed:
// Get response as text
const { data } = await client.GET("/file", { parseAs: "text" });
// Get response as blob
const { data } = await client.GET("/image", { parseAs: "blob" });
// Get response as stream
const { data } = await client.GET("/large-file", { parseAs: "stream" });
// Get response as arrayBuffer
const { data } = await client.GET("/binary", { parseAs: "arrayBuffer" });
Middleware
Middleware intercepts requests and responses for all fetches. Use for auth, logging, caching, error handling, etc.
Middleware Interface
import type { Middleware } from "openapi-fetch";
const myMiddleware: Middleware = {
async onRequest({ request, schemaPath, params, id, options }) {
// Modify or return a new Request
// Return a Response to short-circuit (skip fetch + remaining onRequest handlers)
// Return undefined to skip this middleware
return request;
},
async onResponse({ request, response, schemaPath, params, id, options }) {
// Modify or return a new Response
// Return undefined to skip
return response;
},
async onError({ request, error, schemaPath, params, id, options }) {
// Return nothing: logs without re-throwing
// Return an Error: throws the modified error
// Return a Response: treats the request as successful
},
};
Callback Parameters
| Name | Type | Available in | Description |
|---|---|---|---|
request |
Request |
all | Current Request object |
response |
Response |
onResponse |
Response from endpoint |
error |
unknown |
onError |
Error thrown by fetch |
schemaPath |
string |
all | Original OpenAPI path (e.g., "/blogposts/{post_id}") |
params |
object |
all | Original params object (query, header, path, cookie) |
id |
string |
all | Unique ID for this request |
options |
MergedOptions |
all | Read-only client options |
Registering and Ejecting
client.use(myMiddleware); // Register
client.use(mw1, mw2, mw3); // Register multiple
client.eject(myMiddleware); // Unregister
Execution Order
onRequesthandlers run in registration orderonResponsehandlers run in reverse order
Auth Middleware Pattern
import type { Middleware } from "openapi-fetch";
let accessToken: string | undefined = undefined;
const authMiddleware: Middleware = {
async onRequest({ request }) {
if (!accessToken) {
const authRes = await someAuthFunc();
accessToken = authRes.accessToken;
}
request.headers.set("Authorization", `Bearer ${accessToken}`);
return request;
},
};
client.use(authMiddleware);
Conditional Middleware (Skip Certain Routes)
const UNPROTECTED_ROUTES = ["/v1/login", "/v1/logout", "/v1/public/"];
const authMiddleware: Middleware = {
onRequest({ schemaPath, request }) {
if (UNPROTECTED_ROUTES.some((path) => schemaPath.startsWith(path))) {
return undefined; // Skip this middleware
}
request.headers.set("Authorization", `Bearer ${accessToken}`);
return request;
},
};
Caching Middleware Pattern
const cache = new Map<string, Response>();
const getCacheKey = (request: Request) => `${request.method}:${request.url}`;
const cacheMiddleware: Middleware = {
onRequest({ request }) {
const cached = cache.get(getCacheKey(request));
if (cached) {
return cached.clone(); // Return Response to short-circuit
}
},
onResponse({ request, response }) {
if (response.ok) {
cache.set(getCacheKey(request), response.clone());
}
},
};
Error-Throwing Middleware
By default, openapi-fetch does not throw on 4xx/5xx — it returns { error }. To throw instead:
const throwOnError: Middleware = {
onResponse({ response }) {
if (!response.ok) {
throw new Error(`${response.url}: ${response.status} ${response.statusText}`);
}
},
};
Body Statefulness
Request/Response bodies are stateful (can only be read once). When reading without modification, clone first:
const loggingMiddleware: Middleware = {
async onResponse({ response }) {
const data = await response.clone().json(); // Clone before reading
console.log(data);
return undefined; // Don't modify
},
};
Query Serialization
Object Configuration
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
querySerializer: {
array: {
style: "pipeDelimited", // "form" (default) | "spaceDelimited" | "pipeDelimited"
explode: false, // default: true
},
object: {
style: "form", // "form" | "deepObject" (default)
explode: true, // default: true
},
allowReserved: false, // default: false
},
});
Custom Function
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
querySerializer(queryParams) {
let s = "";
for (const [k, v] of Object.entries(queryParams)) {
if (v !== undefined) {
s += `${s ? "&" : ""}${k}=${encodeURIComponent(String(v))}`;
}
}
return s;
},
});
Per-Request Override
const { data } = await client.GET("/search", {
params: { query: { tags: ["a", "b", "c"] } },
querySerializer: {
array: { style: "pipeDelimited", explode: false },
},
});
Body Serialization
FormData
const { data } = await client.POST("/upload", {
body: { file: myFile, name: "photo" },
bodySerializer(body) {
const fd = new FormData();
for (const [name, value] of Object.entries(body)) {
fd.append(name, value);
}
return fd;
},
});
URL-Encoded
Provide the content-type header and pass body as an object:
const { data } = await client.POST("/form", {
body: { username: "user", password: "pass" },
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
Path Serialization
Custom path parameter formatting:
const client = createClient<paths>({
baseUrl: "https://myapi.dev/v1/",
pathSerializer(pathname, pathParams) {
let result = pathname;
for (const [key, value] of Object.entries(pathParams)) {
result = result.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return result;
},
});
Path-Based Client
Alternative API where methods are accessed via path:
import { createPathBasedClient, wrapAsPathBasedClient } from "openapi-fetch";
import type { paths } from "./my-schema";
// Option 1: Create directly (no middleware support)
const client = createPathBasedClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const { data } = await client["/blogposts/{post_id}"].GET({
params: { path: { post_id: "123" } },
});
// Option 2: Wrap existing client (preserves middleware)
const baseClient = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
baseClient.use(authMiddleware);
const pathClient = wrapAsPathBasedClient(baseClient);
const { data } = await pathClient["/blogposts"].GET();
Note: createPathBasedClient does not support middleware. Use wrapAsPathBasedClient with an existing client if you need middleware.
Testing
Mocking Requests with vi.fn / jest.fn
Supply a mock fetch to the client:
import createClient from "openapi-fetch";
import { expect, test, vi } from "vitest";
import type { paths } from "./my-schema";
test("my request", async () => {
const mockFetch = vi.fn();
const client = createClient<paths>({
baseUrl: "https://my-site.com/api/v1/",
fetch: mockFetch,
});
const reqBody = { name: "test" };
await client.PUT("/tag", { body: reqBody });
const req = mockFetch.mock.calls[0][0];
expect(req.url).toBe("/tag");
expect(await req.json()).toEqual(reqBody);
});
Mocking Responses with MSW
Mock Service Worker is recommended for mocking API responses:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import createClient from "openapi-fetch";
import { afterEach, beforeAll, afterAll, expect, test } from "vitest";
import type { paths } from "./my-schema";
const server = setupServer();
beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
throw new Error(`No request handler found for ${request.method} ${request.url}`);
},
});
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("my API call", async () => {
const rawData = { test: { data: "foo" } };
const BASE_URL = "https://my-site.com";
server.use(
http.get(`${BASE_URL}/api/v1/foo`, () =>
HttpResponse.json(rawData, { status: 200 }),
),
);
const client = createClient<paths>({ baseUrl: BASE_URL });
const { data, error } = await client.GET("/api/v1/foo");
expect(data).toEqual(rawData);
expect(error).toBeUndefined();
});
Important: server.listen() must be called before createClient is used so MSW can intercept requests.
Exported Types
Key types available from openapi-fetch:
import type {
Client,
ClientOptions,
Middleware,
FetchOptions,
FetchResponse,
MaybeOptionalInit,
ParseAs,
QuerySerializer,
QuerySerializerOptions,
BodySerializer,
PathSerializer,
HeadersOptions,
MethodResponse,
ClientPathsWithMethod,
PathBasedClient,
} from "openapi-fetch";
MethodResponse — Extract Response Data Type
import type { MethodResponse } from "openapi-fetch";
// Extract the data type for a specific endpoint
type BlogPost = MethodResponse<typeof client, "get", "/blogposts/{post_id}">;
ClientPathsWithMethod — Get All Paths for a Method
import type { ClientPathsWithMethod } from "openapi-fetch";
type GettablePaths = ClientPathsWithMethod<typeof client, "get">;
MaybeOptionalInit — Init Type for a Path/Method
Determines whether the init parameter is required or optional based on the schema:
import type { MaybeOptionalInit } from "openapi-fetch";
import type { paths } from "./my-schema";
type BlogPostInit = MaybeOptionalInit<paths["/blogposts/{post_id}"], "get">;
Exported Utilities
Helper functions for custom serialization:
import {
createQuerySerializer,
defaultPathSerializer,
defaultBodySerializer,
createFinalURL,
mergeHeaders,
serializePrimitiveParam,
serializeObjectParam,
serializeArrayParam,
removeTrailingSlash,
} from "openapi-fetch";
System Requirements
- Browsers: Modern browsers with Fetch API support
- Node.js: >= 18.0.0
- TypeScript: >= 4.7 (5.0+ recommended)