Skip to main content
Every Voyant module exposes its services over HTTP, and the transport that carries those routes is Hono. Hono is Voyant’s first-class HTTP route interface: small, runtime-portable, and mountable by common application hosts. Packages do not invent a routing framework on top of it. They expose Hono route factories, HonoModules, or HonoExtensions, and a deployment composes them. This page covers the transport, the surface split, the authoring conventions, the -contracts packages that pin the wire types, and how route ownership and composition work.

The Hono transport

@voyant-travel/hono provides createApp(), the middleware chain, auth helpers, and plugin expansion. A deployment builds its app by handing createApp its database client, auth integration, and the set of modules, extensions, and plugins it wants mounted.
import { createApp } from "@voyant-travel/hono"

const app = createApp({
  db: (env) => getDb(env),
  auth: { handler, resolve },
  modules: [crmModule, productsModule, bookingsModule],
  extensions: [smartbillFinanceExtension],
  plugins: [payloadCmsPlugin()],
})
createApp runs a fixed middleware chain before any module route sees the request:
container → requestId → logger → errorBoundary → CORS → health
  → auth handler → requireAuth → db → actor guards → module routes
By the time a module route runs, the request already has a correlation id, a resolved auth context, a database client on c.var.db, and the shared error boundary wrapping it. That is why route handlers can stay short: the cross-cutting work is done.

The surface split

Voyant has three transport buckets, and every route belongs to exactly one of them.
SurfacePurpose
/v1/admin/*Staff and operator routes: back-office CRUD, internal tooling, workflow operations.
/v1/public/*Customer, partner, supplier, and other external-facing contracts.
/auth/*Session and authenticated-user operations.
A HonoModule exposes adminRoutes and publicRoutes. createApp mounts them under /v1/admin/{name} and /v1/public/{name}. So bookingsModule lands at /v1/admin/bookings and /v1/public/bookings without the package hard-coding those prefixes.
storefront is a package and runtime concept (@voyant-travel/storefront, @voyant-travel/storefront-react), not an HTTP namespace. The customer-facing API stays under /v1/public/*, not /v1/public/storefront/*. Do not add a redundant frontend-named URL layer.
Public routes are organized by business capability, not by which frontend calls them: bookings, products, pricing, finance, customer portal. A public URL describes what it exposes, not the app that happens to consume it. A module may override its public mount with publicPath when the package name would add redundant nesting or when the contract is genuinely root-scoped. Use it sparingly; the default is {module.name}. Admin routes deliberately do not get a symmetric adminPath: admin URLs stay tied to the module name so permission derivation, transactional routing, and operator debuggability stay predictable.

A small route example

A well-formed route validates input, leans on the shared auth pipeline, calls a service, and serializes an intentional response.
import { Hono } from "hono"
import { parseJsonBody, parseQuery, requireActor } from "@voyant-travel/hono"
import { createBookingSchema, bookingListQuerySchema } from "./schemas"

export function createBookingRoutes(): Hono {
  const routes = new Hono()

  routes.get("/", async (c) => {
    requireActor(c)
    const query = parseQuery(c, bookingListQuerySchema)
    const bookings = await c.var.container.bookingService.list(query)
    return c.json({ bookings })
  })

  routes.post("/", async (c) => {
    requireActor(c)
    const body = await parseJsonBody(c, createBookingSchema)
    const booking = await c.var.container.bookingService.create(body)
    return c.json({ booking }, 201)
  })

  return routes
}

Request validation

Two shared helpers keep parsing consistent across every package:
  • parseJsonBody(c, schema) for JSON request bodies. It gives every route the same invalid-JSON handling, the same schema-validation responses, and the same error codes and payload shape. Prefer it over open-coded await c.req.json().
  • parseQuery(c, schema) for query strings. Prefer it over manual new URL(...).searchParams plus ad hoc coercion.
Route-level request schemas live close to the route surface that defines them, usually in package schema files. Do not hide request-shape logic in ad hoc middleware or untyped body handling.

Error handling

Routes use the shared API error model rather than hand-crafting JSON for common error classes. The error boundary middleware serializes a consistent envelope, and shared error types cover the usual cases:
  • RequestValidationError
  • UnauthorizedApiError
  • ForbiddenApiError
When a route needs to turn a local branch into a response immediately, it uses handleApiError(...) rather than a hand-built payload. The point is that admin and public responses share one error contract: each package does not invent its own 400, 401, or 403 shape. See Errors and transport for how clients consume that envelope.

The contracts packages

A module’s wire shape is not an accident of its current service internals. It lives in a dedicated -contracts package, for example @voyant-travel/bookings-contracts. That package describes the request and response types shared between the server implementation and the client SDK, so there is one source of truth for the API surface. This is the contract-first idea that runs through the whole framework:
  • The route handler validates and serializes against the contract.
  • The -react package consumes the same contract to give you typed hooks instead of hand-written fetch calls.
  • The public SDKs are typed clients generated against that stable surface.
import type { CreateBookingRequest, BookingResponse } from "@voyant-travel/bookings-contracts"
Treat the response shape as a real surface. That does not mean every route needs a separate DTO file, but it does mean public and admin responses should be shaped on purpose, not leaked by implementation detail.

Route ownership

Reusable route interfaces belong with the thing that owns the capability, not with the deployment. The ownership rules are straightforward:

Modules own capability routes

If a package defines a capability with its own records and behavior, the module owns its admin CRUD, public contracts, route schemas, and route tests.

Extensions own behavior around a module

A route that customizes another module without introducing a new bounded capability is a HonoExtension. Example: finance booking-tax routes mounted under the bookings surface.

Adapters own vendor routes

A package that exists to talk to an external system owns its vendor-specific routes. The deployment supplies credentials and base URLs; the adapter owns the handlers.

Deployments own composition

Starters own DB selection, auth integration, provider credentials, deployment URLs, and genuinely app-specific routes, then compose everything together.
A deployment should learn one typed factory and provide adapters, not copy a route family:
export function createFlightsHonoModule(options: FlightsHonoModuleOptions): HonoModule
The implementation behind that factory can be large. The point is depth: the deployment satisfies a small option interface and gets the whole route family.
There are two ways into the app: composed HonoModule and HonoExtension contributions, or arbitrary additionalRoutes mutations. The second is a migration escape hatch, not a normal authoring surface. New route-bearing code, even deployment-local diagnostics, should enter as a module or extension. A checker (pnpm verify:route-ownership) flags new /v1/* handlers added directly under starters/*.

How an app composes module routes

The operator starter composes its surface from a manifest plus a typed capability container, using composeFromManifest(...) from @voyant-travel/hono/composition. The shape co-locates each runtime unit’s package specifier with its factory and a typed option builder, then derives both the manifest and the registry from that one list.
import { composeFromManifest } from "@voyant-travel/hono/composition"

export const operatorRuntimeUnits = [
  defineRuntimeModule("@voyant-travel/flights", ({ capabilities }) =>
    createFlightsHonoModule(createOperatorFlightsOptions(capabilities)),
  ),
] satisfies RuntimeCompositionUnit[]

export const OPERATOR_RUNTIME_MANIFEST = manifestFromRuntimeUnits(operatorRuntimeUnits)
export const operatorComposition = registryFromRuntimeUnits(operatorRuntimeUnits)

const { modules, extensions } = composeFromManifest(
  OPERATOR_RUNTIME_MANIFEST,
  operatorComposition,
  buildOperatorCapabilities(),
)

export const app = createApp({ db, dbTransactional, modules, extensions, plugins, auth })
Adding a route-bearing module is one runtime unit entry plus any deployment adapters it needs. The deployment capability container is a gathering point for resolvers, but package factories accept their own typed option interfaces rather than the whole container, so a module never becomes a service locator.

Lazy route contributions

A heavy route family (flights, media, catalog offers, document generation) can be loaded on demand. @voyant-travel/hono exposes lazyAdminRoutes / lazyPublicRoutes: a loader () => Promise<Hono> mounted at the surface prefix, dynamically imported on the first matching request and cached per isolate. The framework bridges the request context across the lazy forward, so the loaded sub-app still sees c.var.db, the container, and the resolved actor. A deployment gets the heavy family just by composing the module; there is no starter wiring and no cold-start penalty for routes that are never hit.

Authoring checklist

When you author or review a route:
1

Pick the surface

Decide admin or public, and organize the URL by capability, not by frontend.
2

Validate through the shared helpers

parseJsonBody(...) for bodies, parseQuery(...) for query strings.
3

Use the smallest auth guard

requireUserId(...) for signed-in, actor-aware guards for workspace context, permission checks for explicit grants. See Auth.
4

Delegate to a service

Keep business orchestration in the service or workflow, not the route body.
5

Use shared errors and intentional responses

Serialize through the shared error boundary and shape the response on purpose against the module’s -contracts types.

REST reference

Every route the framework modules expose is documented as structured REST reference, generated from the route handlers and grouped by module. See the Admin API and Storefront API sections in the Framework navigation:
  • Admin API covers the staff-facing /v1/admin/* endpoints.
  • Storefront API covers the customer-facing /v1/public/* endpoints.
These routes run on your own deployed app, so the reference server URL is your app domain, not api.voyant.travel. That is the opposite of the hosted Cloud, Connect, and Data APIs.

Next steps

Services

The business logic routes delegate to.

Auth

Actor types, the auth middleware, API tokens, and scopes.

Caching

What is cacheable on the public surface and how invalidation works.

SDK authentication

How typed clients authenticate against these routes.