Learn how to build production-ready REST APIs with Semola’s type-safe routing, middleware system, and automatic OpenAPI spec generation.
Getting started
Semola’s API framework is built on Bun’s native routing with full TypeScript support and automatic request/response validation.
Basic setup
import { Api } from "semola/api";
import { z } from "zod";
const api = new Api({
prefix: "/api/v1",
openapi: {
title: "User API",
description: "Manage users",
version: "1.0.0",
},
});
Defining routes
Simple GET endpoint
api.defineRoute({
path: "/hello",
method: "GET",
handler: (c) => c.json(200, { message: "world" }),
});
Route with path parameters
api.defineRoute({
path: "/users/:id",
method: "GET",
summary: "Get user by ID",
tags: ["Users"],
request: {
params: z.object({
id: z.uuid(),
}),
},
response: {
200: z.object({
id: z.string(),
name: z.string(),
email: z.email(),
}),
404: z.object({
message: z.string(),
}),
},
handler: async (c) => {
// c.req.params.id is typed as string (validated UUID)
const user = await getUser(c.req.params.id);
if (!user) {
return c.json(404, { message: "User not found" });
}
return c.json(200, user);
},
});
POST endpoint with body validation
const CreateUserSchema = z
.object({
name: z.string().min(1),
email: z.email(),
})
.meta({ id: "CreateUserRequest" });
const UserSchema = z
.object({
id: z.uuid(),
name: z.string(),
email: z.email(),
})
.meta({ id: "User" });
api.defineRoute({
path: "/users",
method: "POST",
summary: "Create a new user",
tags: ["Users"],
request: {
body: CreateUserSchema,
},
response: {
201: UserSchema,
400: z.object({ message: z.string() }),
},
handler: async (c) => {
// c.req.body is typed as { name: string; email: string }
const user = await createUser(c.req.body);
return c.json(201, user);
},
});
Query parameters
api.defineRoute({
path: "/users",
method: "GET",
summary: "List users with pagination",
tags: ["Users"],
request: {
query: z.object({
page: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
}),
},
response: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
},
handler: async (c) => {
const page = c.req.query.page ?? 1;
const limit = c.req.query.limit ?? 10;
const { users, total } = await listUsers(page, limit);
return c.json(200, { users, total });
},
});
Request validation
All request fields are automatically validated before reaching your handler:
- Body: JSON request body (validates Content-Type)
- Params: Path parameters (e.g.,
/users/:id)
- Query: Query string parameters
- Headers: HTTP headers
- Cookies: Parsed from Cookie header
Invalid requests receive 400 Bad Request with detailed error messages.
Validation configuration
Control input and output validation independently:
// Disable all validation
const api = new Api({ validation: false });
// Disable only input validation
const api = new Api({
validation: { input: false },
});
// Disable only output validation
const api = new Api({
validation: { output: false },
});
Output validation catches bugs where your handler returns data that doesn’t match the declared contract. When enabled (default), responses are validated against your response schema.
Middlewares
Middlewares run before your route handler and can:
- Validate authentication
- Add data to the request context
- Short-circuit requests (return early)
- Log requests
Authentication middleware
import { Middleware } from "semola/api";
const authMiddleware = new Middleware({
request: {
headers: z.object({
authorization: z.string(),
}),
},
response: {
401: z.object({ error: z.string() }),
},
handler: async (c) => {
const token = c.req.headers.authorization;
if (!token || !token.startsWith("Bearer ")) {
return c.json(401, { error: "Unauthorized" });
}
const user = await validateToken(token.slice(7));
if (!user) {
return c.json(401, { error: "Invalid token" });
}
// Return data to extend the context
return { user };
},
});
Using middleware in routes
api.defineRoute({
path: "/profile",
method: "GET",
middlewares: [authMiddleware] as const,
response: {
200: z.object({
id: z.string(),
name: z.string(),
}),
},
handler: async (c) => {
// Access middleware data via c.get()
const user = c.get("user");
return c.json(200, {
id: user.id,
name: user.name,
});
},
});
Global middlewares
const loggingMiddleware = new Middleware({
handler: async (c) => {
const start = Date.now();
console.log(`${c.raw.method} ${c.raw.url}`);
return {
requestStartTime: start,
};
},
});
// Apply to all routes
const api = new Api({
middlewares: [loggingMiddleware] as const,
});
Parameterized middleware factories
const createRoleMiddleware = (requiredRole: string) => {
return new Middleware({
response: {
403: z.object({ error: z.string() }),
},
handler: async (c) => {
const user = c.get("user");
if (user.role !== requiredRole) {
return c.json(403, { error: "Forbidden" });
}
return {};
},
});
};
// Use for different routes
api.defineRoute({
path: "/admin",
method: "GET",
middlewares: [authMiddleware, createRoleMiddleware("admin")] as const,
response: {
200: z.object({ message: z.string() }),
},
handler: async (c) => {
return c.json(200, { message: "Admin area" });
},
});
OpenAPI generation
Generating the spec
const spec = await api.getOpenApiSpec();
console.log(JSON.stringify(spec, null, 2));
Optimize your OpenAPI spec by defining reusable schemas:
const UserSchema = z
.object({
id: z.string().uuid(),
name: z.string(),
email: z.email(),
})
.meta({ id: "User" });
const ErrorResponse = z
.object({
error: z.string(),
message: z.string(),
})
.meta({ id: "ErrorResponse" });
// Use across multiple routes
api.defineRoute({
path: "/users",
method: "POST",
request: { body: UserSchema },
response: { 201: UserSchema, 400: ErrorResponse },
handler: async (c) => { /* ... */ },
});
api.defineRoute({
path: "/users/:id",
method: "GET",
response: { 200: UserSchema, 404: ErrorResponse },
handler: async (c) => { /* ... */ },
});
Schemas with an ID are extracted to components.schemas and referenced using $ref instead of being inlined everywhere.
Benefits
- Smaller spec size: Schema defined once, referenced multiple times
- Better maintainability: Update schema in one place
- Improved readability: Cleaner OpenAPI specifications
- Backward compatible: Schemas without
.meta({ id }) are inlined as before
Starting the server
api.serve(3000, () => {
console.log("Server running on http://localhost:3000");
});
Response helpers
The handler context provides convenient response methods:
// JSON response
c.json(200, { message: "Success" })
// Plain text
c.text(200, "Hello World")
// HTML
c.html(200, "<h1>Welcome</h1>")
// Redirect
c.redirect(302, "/new-location")
Complete example
import { z } from "zod";
import { Api } from "semola/api";
const CreateUserSchema = z
.object({
name: z.string().min(1),
email: z.email(),
})
.meta({ id: "CreateUserRequest" });
const UserSchema = z
.object({
id: z.uuid(),
name: z.string(),
email: z.email(),
})
.meta({ id: "User" });
const ErrorSchema = z
.object({
message: z.string(),
})
.meta({ id: "ErrorResponse" });
const api = new Api({
prefix: "/api/v1",
openapi: {
title: "User API",
description: "Manage users",
version: "1.0.0",
},
});
api.defineRoute({
path: "/users",
method: "POST",
summary: "Create a new user",
tags: ["Users"],
request: {
body: CreateUserSchema,
},
response: {
201: UserSchema,
400: ErrorSchema,
},
handler: async (c) => {
const user = await createUser(c.req.body);
return c.json(201, user);
},
});
api.defineRoute({
path: "/users/:id",
method: "GET",
summary: "Get user by ID",
tags: ["Users"],
request: {
params: z.object({
id: z.uuid(),
}),
},
response: {
200: UserSchema,
404: ErrorSchema,
},
handler: async (c) => {
const user = await findUser(c.req.params.id);
if (!user) {
return c.json(404, { message: "User not found" });
}
return c.json(200, user);
},
});
api.serve(3000, () => {
console.log("Server running on http://localhost:3000");
});
Next steps