End-to-End type-safe API Client
Ship type‑safe client definition with zero runtime cost. Generate your OpenAPI typings from your CrumbJS routes and consume them on the frontend (or other backends) with first‑class DX.
One source of truth → your schemas.
Why this approach?
- Single source of truth: Your Zod/OpenAPI schemas define requests/responses; types sync automatically.
- End‑to‑end type safety: Strong typing for params, bodies, success and error shapes.
- Zero runtime codegen: We emit
.d.ts
during development; production builds don’t pay extra cost. - Better DX: Autocomplete for paths, params and response shapes. Safer refactors.
- Predictable errors: Document and type your validation errors once (
spec.invalid
), handle them everywhere. - Framework‑agnostic clients: Works with any frontend (Nuxt, Next, Vite, etc.) and any fetch layer.
- Generate vs Infer reasons
Enable feature
Via ENV:
GENERATE_CLIENT_SCHEMA=true
or in your main App (usually src/index.ts
), enable client schema generation when serving:
src/index.ts
app.serve({
generateClientSchema: true,
});
Notes
- Emits
ROOT/client.d.ts
(your API typings) only when mode =development
and withOpenapi =true
at server startup. - When running the development server with
bun run dev
, the file is updated almost instantly on every change. - Adds a few extra milliseconds to startup. For example, ~60ms with ~200 endpoints on a typical dev machine. Numbers are indicative; your mileage may vary.
Example
Server side
Define request/response schemas. They will flow into the generated client typings:
Server
import { z, spec } from "@crumbjs/core";
// The schema will be used to validate `query` and to describe possible invalid responses
const querySchema = z.object({ name: z.string().min(3) });
app.get(
"/hello-by-query",
({ query }) => ({ message: `Hello ${query.name}` }),
{
query: querySchema,
responses: [
spec.response(200, z.object({ message: z.string() })), // client success type
spec.invalid(querySchema), // * client error type (auto-generated field errors from this schema)
],
}
);
*In this example we set the spec.invalid but remember that can be "auto-configured"
Generated client types (simplified):
Success Type
const data:
| {
message: string;
}
| undefined;
Error Type
const error:
| {
status: number;
message: string;
fields: {
name?: string[];
};
}
| undefined;
Client side
Install the client helpers:
npm i openapi-fetch
npm i -D openapi-typescript typescript
Use the generated types with openapi-fetch
:
Frontend
import createClient from "openapi-fetch";
// In this example, `@api` is a tsconfig path alias pointing to the CrumbJS server root.
// You can also import relatively (e.g., "../../server/client.d.ts") if you prefer.
import type { paths } from "@api/client";
const client = createClient<paths>({ baseUrl: "http://localhost:8081" });
const { data, error } = await client.GET("/api/hello-by-query", {
params: {
query: { name: "CrumbJS" },
},
});
if (error) {
console.error(error);
/**
* {
* status: 400,
* message: "Invalid Query",
* fields: {
* name: ["Too small: expected string to have >=3 characters"],
* },
* }
*/
}
console.log(data);
/**
* { message: "Hello CrumbJS" }
*/
Troubleshooting
Cannot find module '@api/client'
→ Add a path alias in your tsconfig or import relatively.fetch
/CORS issues → Ensurecors()
middleware is enabled/configured in your server.- Out‑of‑date types → Regenerate by restarting dev server (types are only emitted in dev +
withOpenapi=true
).
Why generate a client schema (vs inferring from App
instance like other frameworks)?
- No style lock-in: Fluent routes are supported—but never required.
- No type spaghetti: Avoids complex merges; keeps @crumbjs/core flat, flexible, and maintainable.
- Happier IDE/TS: Less deep instantiation/inference overhead.
- Single, portable contract: one file for the entire API—diff-friendly and PR-readable.
- Zero runtime cost: generated in dev/build; nothing shipped to prod.