Skip to content

Every backend generator accepts a templates option. When you create a new route file whose name matches one of your patterns, your boilerplate is written into it instead of the built-in placeholder.

Templates seed route index files - api/**/index.ts - and nothing else. Every other seeded file gets the built-in minimal boilerplate to start from.

Configuration

Pass templates through the backend block in the source folder's kosmo.config.ts, keyed by route-name pattern:

kosmo.config.ts
ts
import { defineConfig } from "@kosmojs/dev";

const crudTemplate = `
import { defineRoute } from "_/api";

export default defineRoute<"{{route.name}}">(({ GET, POST, PUT, DELETE }) => [
  GET(async (ctx) => { /* list or read */ }),
  POST(async (ctx) => { /* create */ }),
  PUT(async (ctx) => { /* update */ }),
  DELETE(async (ctx) => { /* delete */ }),
]);`;

export default defineConfig({
  backend: {
    stack: "hono",
    base: "/api",
    templates: {
      "admin/**": crudTemplate,
    },
  },
});

Keys match the route name - the path relative to api/, without the trailing index.ts. So api/admin/users/index.ts is matched as admin/users.

Pattern Syntax

Patterns are picomatch globs matched against the route name.

Single-depth wildcard (*)

ts
{ "admin/*": template }

Matches: admin/users, admin/settings, admin/[id]Excludes: admin/users/roles (too deep), admin (too shallow)

Multi-depth wildcard (**)

ts
{ "admin/**": template }

Matches: admin/users, admin/users/roles, admin/[id]/audit - any depth.

Exact match

ts
{ "health": template }

Parameter segments are literal

Route parameters are escaped before matching, so [id], {id} and {...path} mean themselves rather than glob syntax:

ts
{
  "users/[id]": userTemplate,           // required parameter
  "products/{category}": productTemplate, // optional parameter
  "docs/{...path}": docsTemplate,        // splat parameter
  "shop/[category]/{sub}": shopTemplate, // combined
}

Resolution Priority

The first matching pattern wins, in the order the keys are written - so order them most specific first:

ts
templates: {
  "admin/audit": auditTemplate,   // exact
  "admin/*": adminTemplate,       // one level
  "**": fallbackTemplate,         // everything else
}

A route matching nothing gets the built-in placeholder. Set "**" to replace that default everywhere.

Numeric-looking patterns jump the queue

JavaScript objects order integer-like keys first, regardless of where you wrote them - so a pattern such as "2024/**" is hoisted to the front and matches before anything above it. Prefix it with ./ to keep your written order: "./2024/**". The ./ is stripped before matching.

The same applies to renderMode, which uses the same resolver.

Template Format

A template is either a string, or a function of the resolved route returning a string - reach for the function form when the output depends on the route itself:

ts
templates: {
  // plain string, rendered with Handlebars
  "admin/**": crudTemplate,

  // or computed from the route
  "reports/**": (route) => `
import { defineRoute } from "_/api";

// endpoint: ${route.name}
export default defineRoute<"${route.name}">(({ GET }) => [
  GET(async (ctx) => { /* ... */ }),
]);`,
}

String templates are rendered with Handlebars against a { route } context, so a {{route.name}} placeholder interpolates the route name - which is what keeps the required route-name type argument correct in seeded files.

Per-backend shapes

Because the context API differs by backend, so does the boilerplate:

ts
// Hono: kosmo.config.ts
const template = `
import { defineRoute } from "_/api";

export default defineRoute<"{{route.name}}">(({ GET }) => [
  GET(async (ctx) => {
    // Always \`return\` the response
    return ctx.json({ ok: true });
  }),
]);`;

Escape backticks (\`) and ${...} inside template literals that you do not want interpolated at config-evaluation time.

Seeding CRUD Endpoints

This is where route templates earn their place. Standing up endpoints for a dozen database tables means writing the same skeleton a dozen times - validation targets, method handlers, error shape, all identical apart from the type.

Write it once:

kosmo.config.ts
ts
const resourceTemplate = `
import { defineRoute } from "_/api";
import type { Resource, ResourcePayload } from "./types";

export default defineRoute<"{{route.name}}">(({ GET, POST }) => [
  GET<{
    query: { page?: number; limit?: number },
    response: [200, "json", Array<Resource>],
  }>(async (ctx) => {
    // list
  }),

  POST<{
    json: ResourcePayload,
    response: [201, "json", Resource],
  }>(async (ctx) => {
    // create
  }),
]);`;

export default defineConfig({
  backend: {
    stack: "hono",
    base: "/api",
    templates: { "admin/**": resourceTemplate },
  },
});

Then create the route folders. Each new index.ts arrives with the right structure - typed handlers, declared response (so the fetch client is typed and OpenAPI picks it up), ready to adapt instead of retyped.

Pair it with a colocated types.ts per route folder and the skeleton stays honest: the template references ./types, and each route defines its own.

Common Use Cases

ts
backend: {
  stack: "hono",
  base: "/api",
  templates: {
    "admin/**": crudTemplate,        // consistent admin endpoints
    "webhooks/**": webhookTemplate,  // signature verification + 200 fast
    "internal/**": internalTemplate, // runtimeValidation: false, trusted callers
  },
}

Released under the MIT License.