Skip to main content
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));

Schema reuse with .meta({ id })

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

1
Define schemas
2
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" });
3
Create API instance
4
const api = new Api({
  prefix: "/api/v1",
  openapi: {
    title: "User API",
    description: "Manage users",
    version: "1.0.0",
  },
});
5
Define routes
6
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);
  },
});
7
Start the server
8
api.serve(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Next steps

Build docs developers (and LLMs) love