Cloud|

Build typed HTTP APIs

Validate a Hono endpoint, publish its contract, and use its typed browser client.

4 min read Updated 2026-10-11 #server#hono#validation#clients

A Cloud JSON endpoint has one type path:

text
Zod input → Hono route → service Result → Hono client

Do not create a separate browser response type. Export the final Hono router type.

Define request and response schemas

Start with the wire format:

ts
import { z } from "zod";

export const InventoryItemSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  quantity: z.number().int(),
});

export const CreateInventoryItemSchema =
  InventoryItemSchema.omit({ id: true });

These schemas validate HTTP data. Domain and database types may differ.

Validate every request value

Use v() before the handler:

ts
import { v } from "@k2b/cloud/server";

const ItemParamSchema = z.object({
  id: z.string().uuid(),
});

const ListItemsQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  direction: z.enum(["asc", "desc"]).default("asc"),
});
Target Reads
"json" JSON request body
"query" URL query parameters
"param" Path parameters
"header" Request headers
"cookie" Cookies
"form" Form data

Read the typed value with c.req.valid(target).

Query and path values arrive as strings. Coerce numbers and booleans in the schema. Use enums for sort fields and directions before they reach SQL.

Input that fails the schema returns status 400 with { message }, or { code, message } when the route passes an error resolver. The handler does not run. A JSON or form body that cannot be parsed never reaches the schema: it returns 400 with a plain-text message.

Validation checks the wire shape. The service checks resource access, existing state, and business rules.

Write the service operation

The service owns the business rule:

ts
import {
  type AccessSubject,
  ok,
  type Result,
} from "@k2b/cloud/server";

type CreateInventoryItem = z.infer<typeof CreateInventoryItemSchema>;
type InventoryItem = z.infer<typeof InventoryItemSchema>;

export const createInventoryService = (repository: InventoryRepository) => ({
  create: async (
    input: CreateInventoryItem & { accessSubject: AccessSubject },
  ): Promise<Result<InventoryItem>> => {
    const item = await repository.create(input);
    return ok(item);
  },
});

Pass the access subject into operations that read or change protected resources.

See Services and Result.

Add the route

The route applies transport concerns:

ts
import { ErrorResponseSchema } from "@k2b/cloud/contracts";
import {
  type AuthContext,
  auth,
  jsonResponse,
  respond,
  v,
} from "@k2b/cloud/server";
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";

const itemRoutes = new Hono<AuthContext>()
  .use("*", auth.requireRole("authenticated"))
  .post(
    "/",
    describeRoute({
      tags: ["Inventory"],
      summary: "Create an inventory item",
      responses: {
        201: jsonResponse(
          InventoryItemSchema,
          "Inventory item created",
        ),
        400: jsonResponse(ErrorResponseSchema, "Invalid input"),
        401: jsonResponse(ErrorResponseSchema, "Authentication required"),
      },
    }),
    v("json", CreateInventoryItemSchema),
    async (c) =>
      respond(
        c,
        inventory.create({
          ...c.req.valid("json"),
          accessSubject: c.get("accessSubject"),
        }),
        201,
      ),
  );

The route:

  1. authenticates the caller;
  2. validates the JSON body;
  3. passes typed input to the service;
  4. converts the service result to JSON.

Authorization for a particular item belongs in the service. See Resource authorization.

Answer slow requests

Cloud app servers and the gateway close connections after 10 seconds without traffic. After reading and validating the body, a handler that needs more time can call setRequestTimeout(c, seconds) from @k2b/cloud/server. Each call restarts the idle timer. Only integer values from 1 to 240 are accepted; other values throw RangeError. The 240-second maximum matches how long the gateway waits for an app's response headers. It stays below Bun's limit of 255 seconds per connection, so the gateway can still answer a timed-out upstream with 502.

Call setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS) (10) in finally once the handler starts answering, including error responses. Bun retains a raised timeout on the keep-alive socket after the response, so every raise must be restored. The helper and both constants, REQUEST_IDLE_TIMEOUT_SECONDS and REQUEST_IDLE_TIMEOUT_MAX_SECONDS (240), are public server exports.

The helper does nothing outside Bun's HTTP server, such as in app.request() tests. Prefer streaming with regular traffic or a background job for longer work.

Export the final router type

Compose all subrouters before exporting the type:

ts
const apiRoutes = new Hono<AuthContext>()
  .route("/items", itemRoutes)
  .route("/warehouses", warehouseRoutes);

export type ApiType = typeof apiRoutes;
export default apiRoutes;

Do not export the type of an earlier base router. Routes added later would be missing from the browser client.

Mount the same router in the application:

ts
router.route("/api/inventory", apiRoutes);

Publish OpenAPI

Describe each public route:

ts
import { ErrorResponseSchema } from "@k2b/cloud/contracts";
import { jsonResponse } from "@k2b/cloud/server";
import { describeRoute } from "hono-openapi";

describeRoute({
  tags: ["Inventory"],
  summary: "Create an inventory item",
  responses: {
    201: jsonResponse(InventoryItemSchema, "Inventory item created"),
    400: jsonResponse(ErrorResponseSchema, "Invalid input"),
    401: jsonResponse(ErrorResponseSchema, "Authentication required"),
  },
});

middleware.openapi() is the same metadata helper under the Cloud middleware namespace. imageResponse() describes an image/webp response.

Declare the document path:

ts
export const app = defineApp({
  // required fields
  openapi: "/api/inventory/openapi.json",
});

Pass the bare annotated API router to app.start():

ts
export default await app.start({
  fetch: router.fetch,
  openapi: apiRoutes,
});

Both options are required. The bare router contains paths such as /items. Cloud derives /api/inventory from the document path.

Cloud serves the document before application middleware. Keep secrets, internal hostnames, and private examples out of route metadata.

OpenAPI security metadata describes accepted credentials. It does not enforce access. Add the matching route policy.

Document every response the route and its middleware can return. An authenticated route normally includes 401. Add 403 when a role policy or resource check can deny an authenticated caller.

Every documented status must be reachable. Every response body must match its schema.

Cloud documents validation failures for you. Every route that validates input and does not describe its own 400 gets a 400 response with the body v() sends when input fails the schema: a JSON object with a required message string and an optional code string. Describe the 400 yourself when the route returns a different body, for example a validation hook with its own error format; your description replaces the default.

Create the browser client

Use the exported router type:

ts
import { api } from "@k2b/cloud/browser";
import type { ApiType } from ".";

export const inventoryApi = api.create<ApiType>({
  baseUrl: "/api/inventory",
});

Call the route without a manual response type:

ts
const response = await inventoryApi.items.$post({
  json: {
    name: "USB-C adapter",
    quantity: 12,
  },
});

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.message);
}

const item = await response.json();

If the result becomes any, unknown, or needs response.json() as Type, fix the server route type.

See Browser clients and mutations before wiring the call to UI.

Use raw responses for non-JSON data

Raw Response is correct for:

  • streams and server-sent events;
  • file downloads;
  • image or binary bodies;
  • reverse proxies;
  • WebSocket upgrades.

Avoid a broad raw response branch in an ordinary JSON route. It can widen the generated Hono client type.

Add list and reference behavior

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.