Skip to main content

End-to-End type-safe API Client

Feature powered by OpenAPI‑TS

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 → Ensure cors() 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.