Skip to main content

Overview

Every API route in folksbase follows the same pattern: a Hono route handler with OpenAPI documentation via hono-openapi, request validation via Zod, and strict adherence to the layered architecture (Routes → Services → Repositories → Drizzle). This guide walks through adding a new route from scratch.

The Layered Architecture

Before writing any route code, understand the layers:
LayerResponsibilityLocation
RouteHTTP concerns only — parse request, call service, return responsesrc/routes/*.ts
ServiceBusiness logic, orchestration, validationsrc/services/*.ts
RepositorySQL queries only, no business logicsrc/repositories/*.ts
Hard rules:
  • Routes never import from @folksbase/db
  • Services never construct HTTP responses
  • Repositories never contain business logic

Step 1: Define Your Zod Schemas

Start with the request and response schemas. If they’re reusable, add them to apps/api/src/lib/openapi-schemas.ts. Otherwise, define them in the route file.
import { z } from "zod";

const createBodySchema = z.object({
  name: z.string().min(1).max(100),
  color: z.string().max(20).optional(),
});

const updateBodySchema = createBodySchema.partial();

Step 2: Create the Route File

Create a new file in apps/api/src/routes/. Import the OpenAPI helpers and auth middleware:
import { errorSchema } from "@/lib/openapi-schemas.js";
import { type Variables, auth } from "@/middleware/auth.js";
import { Hono } from "hono";
import { describeRoute, resolver, validator as zValidator } from "hono-openapi";
import { z } from "zod";

Step 3: Add Route Handlers with OpenAPI Docs

Every route must use describeRoute() for response documentation and zValidator() for request validation:
export const widgetsRoute = new Hono<{ Variables: Variables }>()
  .use("*", auth)

  .get(
    "/",
    describeRoute({
      tags: ["Widgets"],
      summary: "List widgets",
      description: "Returns all widgets for the workspace.",
      responses: {
        200: {
          description: "Widget list",
          content: {
            "application/json": {
              schema: resolver(z.array(widgetSchema)),
            },
          },
        },
      },
    }),
    async (c) => {
      const { workspaceId } = c.get("user");
      const widgets = await widgetsService.findAll(workspaceId);
      return c.json(widgets);
    },
  )

  .post(
    "/",
    describeRoute({
      tags: ["Widgets"],
      summary: "Create widget",
      responses: {
        201: {
          description: "Widget created",
          content: {
            "application/json": { schema: resolver(widgetSchema) },
          },
        },
        400: {
          description: "Validation error",
          content: {
            "application/json": { schema: resolver(errorSchema) },
          },
        },
      },
    }),
    zValidator("json", createBodySchema),
    async (c) => {
      const { workspaceId } = c.get("user");
      const body = c.req.valid("json");
      const widget = await widgetsService.create(workspaceId, body);
      return c.json(widget, 201);
    },
  );

Key Patterns

  • Path params use zValidator("param", schema) — always enforce UUID format:
    zValidator("param", z.object({ id: z.string().uuid() }))
    
  • Query params use zValidator("query", schema)
  • JSON body uses zValidator("json", schema)
  • Access validated data via c.req.valid("query"), c.req.valid("json"), or c.req.valid("param")

Step 4: Register the Route

Add your route to apps/api/src/index.ts:
import { widgetsRoute } from "@/routes/widgets.js";

// Inside the app setup
app.route("/api/widgets", widgetsRoute);

Step 5: Error Handling

Don’t add manual try/catch blocks in route handlers. Let errors propagate to the global errorHandler middleware, which handles ZodError and auth errors automatically and returns consistent { code, message } responses. The only exception is when you need to return a specific 404:
const widget = await widgetsService.findById(workspaceId, id);
if (!widget) {
  return c.json({ code: "NOT_FOUND", message: "Widget not found" }, 404);
}

Step 6: Verify

After adding your route:
  1. Start the dev server: pnpm --filter @folksbase/api dev
  2. Open http://localhost:3001/api/docs — your new route should appear in the Scalar UI
  3. Check the OpenAPI spec at http://localhost:3001/api/openapi.json
  4. Write unit tests in src/routes/__tests__/ following the existing patterns

Checklist

Before submitting your PR, verify:
  • Route uses describeRoute() with tags, summary, and response schemas
  • Request validation uses zValidator() (not manual .parse())
  • Route only calls services — no direct DB imports
  • Path params enforce UUID format where applicable
  • No manual try/catch — errors propagate to global handler
  • Unit tests use valid UUIDs for path parameters
  • Route is registered in src/index.ts