Type-safe route definitions with auto-generated clients and OpenAPI docs
npm install @fresho/routerType-safe routing for Cloudflare Workers, Deno, Bun, and Node.js. Define routes once, get validated handlers, typed clients, and OpenAPI docs.
~2KB gzipped. Zero dependencies.
``typescript
import { route, router } from "@fresho/router";
const api = router({
health: router({
get: async () => ({ status: "ok" }),
}),
users: router({
// GET /users - list with optional limit
get: route({
query: { limit: "number?" },
handler: async (c) => db.users.list(c.query.limit),
}),
// POST /users - create user
post: route({
body: { name: "string", email: "string" },
handler: async (c) => db.users.create(c.body),
}),
// GET /users/:id - get by id
$id: router({
// Bare function shorthand instead of route type
get: async (c) => db.users.get(c.path.id),
}),
}),
});
// Cloudflare Worker / Deno / Bun
export default { fetch: api.handler() };
`
- Type-safe path params — $id creates dynamic segments, typed via route.ctx<>()
- Schema validation — query and body validated at runtime, typed at compile time
- Property-based routing — property names become URL segments
- Typed HTTP client — call your API with full type safety
- Typed local client — test handlers directly without HTTP
- OpenAPI generation — auto-generate docs from your routes
- Middleware — cors, auth, rate limiting, and more
- Streaming — SSE and JSON lines helpers
- HTTP-compliant — automatic HEAD support for GET routes
- Zero dependencies — just Web APIs
`bash`
npm install @fresho/router
Property names become URL path segments:
`typescript`
router({
api: router({ // /api
users: router({ // /api/users
get: async () => ...,
$id: router({ // /api/users/:id
get: async (c) => c.path.id,
}),
}),
}),
});
- Regular properties → static segments (users → /users)$param
- properties → dynamic segments ($id → /:id)get
- HTTP methods (, post, etc.) → handlers at that path
Define query and body schemas using shorthand syntax:
`typescript
// Primitives
{ name: 'string' } // required string
{ age: 'number' } // required number (coerced from string in query)
{ active: 'boolean' } // required boolean (accepts "true"/"false"/"1"/"0")
// Optional (append ?)
{ name: 'string?' } // optional string
// Arrays
{ tags: 'string[]' } // string array
{ scores: 'number[]' } // number array
// Nested objects
{
address: {
street: 'string',
city: 'string',
zip: 'number?'
}
}
`
Types are automatically inferred:
`typescript`
post: route({
body: {
title: "string",
tags: "string[]",
metadata: { priority: "number", draft: "boolean?" },
},
handler: async (c) => {
c.body.title; // string
c.body.tags; // string[]
c.body.metadata; // { priority: number, draft: boolean | undefined }
},
});
Schemas (query/body) provide runtime validation AND type inference:
`typescript`
get: route({
query: { limit: "number?" },
handler: async (c) => c.query.limit, // number | undefined
});
Context (route.ctx) provides types only for things schemas don't cover:
`typescript
interface MyContext {
path: { id: string }; // from $id segment
env: { DB: Database }; // runtime environment
user: { name: string }; // from auth middleware
}
get: route.ctx
query: { include: "string?" }, // validated
handler: async (c) => ({
id: c.path.id, // from context
user: c.user.name, // from context
include: c.query.include, // from schema
}),
});
`
Don't add explicit type annotations to handlers — let types flow from schemas and context:
`typescript
// GOOD: types inferred
handler: async (c) => c.query.limit;
// BAD: redundant annotation
handler: async (c: { query: { limit?: number } }) => c.query.limit;
`
Compose routers:
`typescript
const users = router({
get: async () => db.users.list(),
post: route({
body: { name: 'string' },
handler: async (c) => db.users.create(c.body),
}),
$id: router({
get: async (c) => db.users.get(c.path.id),
delete: async (c) => db.users.delete(c.path.id),
}),
});
const api = router({
users,
posts: router({ ... }),
});
// Routes:
// GET /users
// POST /users
// GET /users/:id
// DELETE /users/:id
`
Add middleware to routers:
`typescript
import { router, route } from "@fresho/router";
import { cors, errorHandler, jwtAuth } from "@fresho/router/middleware";
const api = router(
{
hello: router({
get: async () => ({ message: "world" }),
}),
},
cors(),
errorHandler(),
jwtAuth({ secret: process.env.JWT_SECRET, claims: (p) => ({ user: p.sub }) })
);
`
Built-in middleware: cors, errorHandler, logger, rateLimit, requestId, timeout, basicAuth, bearerAuth, jwtAuth, contentType.
See Middleware Documentation for detailed usage.
For standalone auth utilities (JWT signing/verification, Basic auth, OAuth), import from @fresho/router/auth:
`typescript
import { jwtSign, jwtVerify, parseBasicAuth } from "@fresho/router/auth";
// Sign a JWT
const token = await jwtSign({ uid: "user-123" }, secret, { expiresIn: "1h" });
// Verify a JWT
const payload = await jwtVerify(token, secret);
// Parse Basic auth header
const creds = parseBasicAuth(request.headers.get("Authorization"));
`
Available utilities:
- JWT: jwtSign, jwtVerifyparseBasicAuth
- Basic Auth: , encodeBasicAuth, extractBasicAuthTokenencodeOAuthState
- OAuth: , decodeOAuthState, exchangeCode, refreshAccessToken, revokeToken, buildAuthorizationUrl, OAUTH_PROVIDERS
See Authentication Documentation for detailed usage.
Generate a typed client for your API:
`typescript
// === Server (api.ts) ===
import { route, router } from "@fresho/router";
export const api = router({
users: router({
get: route({
query: { limit: "number?" },
handler: async (c) => ({ users: [], limit: c.query.limit }),
}),
$id: router({
get: async (c) => ({ id: c.path.id, name: "Alice" }),
}),
}),
});
// === Client ===
import { createHttpClient } from "@fresho/router";
import type { api } from "./api"; // Type-only import!
const client = createHttpClient
baseUrl: "https://api.example.com",
});
// Direct call for implicit GET
const users = await client.users({ query: { limit: 10 } });
// Path params
const user = await client.users.$id({ path: { id: "123" } });
// user is typed as { id: string, name: string }
// Explicit HTTP methods use $-prefix
await client.users.$get(); // GET /users
await client.users.$post({ body: { name: "Bob" } }); // POST /users
`
HTTP methods are prefixed with $ to distinguish them from path navigation:
`typescript
// $-prefixed = execute HTTP method
client.users.$get(); // GET /users
client.users.$post({}); // POST /users
client.users.$put({}); // PUT /users
client.users.$delete(); // DELETE /users
// No prefix = navigate to path segment
client.api.get.$get(); // GET /api/get (navigate to "get", then execute GET)
`
This allows routes with path segments named after HTTP methods (e.g., /api/get, /resources/delete).
Test handlers directly without HTTP overhead:
`typescript
import { createLocalClient } from "@fresho/router";
const client = createLocalClient(api);
client.configure({ env: { DB: mockDb } });
const user = await client.users.$id({ path: { id: "123" } });
assert.equal(user.name, "Alice");
`
Generate OpenAPI 3.0 documentation:
`typescript
import { generateDocs } from "@fresho/router";
const spec = generateDocs(api, {
title: "My API",
version: "1.0.0",
});
// Serve at /openapi.json
const docs = router({
openapi: router({
get: async () => spec,
}),
});
`
`typescript
import { sseResponse } from "@fresho/router";
events: router({
get: async () =>
sseResponse(async (send, close) => {
send({ data: "connected" });
send({ event: "update", data: { count: 1 } });
close();
}),
});
`
`typescript
import { streamJsonLines } from "@fresho/router";
logs: router({
get: async () =>
streamJsonLines(async (send, close) => {
send({ level: "info", message: "Starting..." });
send({ level: "info", message: "Done" });
close();
}),
});
`
`typescript
import { route, router } from '@fresho/router';
const api = router({ ... });
export default {
fetch: api.handler(),
};
`
Use route.ctx for environment bindings and middleware-added properties:
`typescript
interface AppContext {
env: { DB: D1Database };
user: { id: string }; // from auth middleware
}
const api = router(
{
profile: router({
get: route.ctx
handler: async (c) => {
const data = await c.env.DB.prepare("...").all();
return { userId: c.user.id, data };
},
}),
}),
},
jwtAuth({
secret: (c) => c.env.JWT_SECRET,
claims: (p) => ({ user: { id: p.sub } }),
})
);
export default { fetch: api.handler() };
`
Per RFC 9110, HEAD requests are automatically handled for any GET route:
`typescript
users: router({
get: async () => ({ users: await db.getUsers() }),
});
// GET /users → 200 with body
// HEAD /users → 200 with no body (same headers)
`
Define an explicit head handler if you need different behavior:
`typescript``
users: router({
head: async () => new Response(null, { headers: { "X-Count": "100" } }),
get: async () => ({ users: await db.getUsers() }),
});
| Usage | Minified | Gzipped |
| --------------------------- | -------- | ------- |
| Core (routing + validation) | 4.2 KB | 1.9 KB |
| + HTTP client | 5.8 KB | 2.4 KB |
| + OpenAPI docs | 5.3 KB | 2.3 KB |
| + cors, errorHandler | 6.5 KB | 2.7 KB |
| + all middleware | 11.6 KB | 4.4 KB |
Tree-shakeable: only pay for what you import.
MIT