GenerateSaaS

API Layer

Build on the @repo/api Hono backend with typed RPC, public /v1 routes, and user API keys.

The backend is one Hono app in @repo/api, exporting a configured app plus the AppType type that powers end-to-end RPC. Everything mounts under apiBasePath (the pathname of your API URL): internal routes back the Nuxt frontend, public /v1 routes serve external integrations.

Route tree

packages/api/src/index.ts composes internal, public, and Inngest sub-routers into one routes object.

GroupMountAccessSource
Health/v1/healthPublicroutes/public/v1/health.ts
IP/v1/ipPublicroutes/public/v1/ip.ts
Content/v1/contentPublic when config.contentApi.enabledroutes/public/v1/content.ts
OpenAPI spec/openapi.json, /llms.txtPublicroutes/public/index.ts
Auth, billing, dashboard/auth, /billing, /dashboardInternalroutes/internal/
Admin, API keys, audit/admin, /api-keys, /auditInternalroutes/internal/
Notifications, storage, geo/notifications, /storage, /geoInternalroutes/internal/
Background jobs/inngestInngest-signedroutes/inngest.ts
  • /v1/health returns { status: "success", version } with an ETag; deploy smoke tests read version.
  • /v1/content is filesystem-backed CRUD over markdown (Node.js runtimes only), gated by config.contentApi.enabled plus an X-Content-Api-Key header matching CONTENT_API_KEY; when off the route is never mounted.
  • CORS is driven by config.origins with credentials: true. A global limiter caps external traffic at 200 requests / 60s; internal requests skip it via isInternalRequest.

Typed RPC and validation

AppType captures every route, but only under two rules.

  • Chain handlers: keep each router on one fluent new Hono().get(...).post(...) chain. Reassigning app between calls drops routes from inference.
  • Validate with sValidator from @hono/standard-validator against Zod schemas; read parsed input with c.req.valid("json" | "query" | "param").
// packages/api/src/index.ts
const routes = new Hono()
  .route("/", internalRoutes)
  .route("/", publicRoutes)
  .route("/", inngestRoutes);

app.basePath(apiBasePath).route("/", routes);
export type AppType = typeof routes;

Scalar API docs

When config.apiDocs is true (default), an interactive Scalar reference mounts at {apiBasePath}/docs - /api/docs, not /api/v1/docs - reading {apiBasePath}/openapi.json. Set it false to remove the route.

config.performanceMonitor.enabled (default true) toggles the Hono request logger (middleware/performance.ts, logs METHOD /path STATUS Nms cached). See Performance monitoring.

The Nuxt client

The typed client lives at apps/web-nuxt/app/utils/api.ts - hc<AppType> wired to NUXT_PUBLIC_API_URL with credentials: "include" and a custom apiFetch (api-fetch.ts). Never import the backend into app code.

  • Browser: plain globalThis.fetch; the cookie rides on credentials.
  • SSR, same-origin: forwards the incoming cookie header and routes the call through Nuxt's $fetch.raw (path-only) - in-process, no self-request HTTP hairpin.
  • SSR, cross-origin (separate backend): absolute globalThis.fetch, still forwarding the cookie.

Call api inside useAsyncData; for authenticated reads pair it with authClient.useSession(ssrFetch). See Data fetching for the full SSR patterns.

User API keys

User-facing keys (the Better Auth api-key plugin) are gated by config.apiKeys.enabled and served from routes/internal/api-keys.ts behind authGuard. Each key bakes in a per-plan rate limit at creation, resolved by resolveApiKeyRateLimitPlan (@repo/payments) from pricingConfig. Plugin config, routes, and quotas: API keys.

Frequently asked questions

Why doesn't my new route appear in the client's types? The route chain was broken. Keep every .route()/.get() on the single fluent chain that feeds export type AppType = typeof routes - a detached call is invisible to inference.

Why are the docs at /api/docs and not under /v1? Scalar mounts at the base path ({apiBasePath}/docs), not the versioned namespace. Toggle it with config.apiDocs.

Do SSR calls make a real HTTP request back to the server? No. For same-origin SSR, apiFetch routes through Nuxt's $fetch.raw in-process, avoiding the self-request hairpin. Cross-origin (separate backend) does use real fetch.

Can an external client call internal routes with an API key? A valid key authenticates any route that accepts it, but /v1/* is the documented public surface. Treat non-/v1 groups as first-party.

On this page