Adding a feature
This walkthrough adds a fictional “posts” feature end to end, following the
conventions in AGENTS.md. The same pattern applies to any new URL namespace.
1. Add the route file
Section titled “1. Add the route file”Create src/server/routes/posts.routes.ts. One file per URL namespace — GET
renders and POST actions live together.
import { Type as t } from "@sinclair/typebox";import { Hono } from "hono";import { requireAuth } from "../auth";import { createPost, listPosts } from "../db";import type { AppEnv } from "../inertia-middleware";import { validateJson } from "../validation";
const postBody = t.Object( { title: t.String({ minLength: 1 }), body: t.String({ minLength: 1 }) }, { additionalProperties: false },);
export const postsRoutes = () => { const app = new Hono<AppEnv>();
app.get("/posts", requireAuth, async (c) => { const posts = await listPosts(); return c.var.inertia.render("Posts", { posts }); });
app.post("/posts", requireAuth, validateJson(postBody), async (c) => { const body = c.req.valid("json"); await createPost(body.title, body.body, c.var.user!.id); return c.var.inertia.redirect("/posts"); });
return app;};Mount it in src/server/app.ts:
app.route("/", postsRoutes());Conventions
Section titled “Conventions”- Export
const <feature>Routes = () => new Hono<AppEnv>()…— a factory taking no arguments. - Route-specific logic stays inline; extract a module only when reused across routes.
pages.routes.tsis the app shell only (/,/dashboard,/admin). New feature pages do not go there.- Infra endpoints (
/health) stay inapp.ts.
2. Add SQL in db.ts
Section titled “2. Add SQL in db.ts”All SQL lives in src/server/db.ts as async query functions. Never scatter
queries across feature folders.
export const createPost = (title: string, body: string, userId: number) => d1 .prepare("INSERT INTO posts (title, body, user_id) VALUES (?, ?, ?) RETURNING id") .bind(title, body, userId) .first<{ id: number }>();
export const listPosts = async () => (await d1.prepare("SELECT id, title, body FROM posts ORDER BY id DESC").all<PostRow>()).results;Use parameterized binds (?) — never string interpolation. All queries are
async / await.
3. Add a validation schema (TypeBox)
Section titled “3. Add a validation schema (TypeBox)”Validate request bodies at the route level with TypeBox schemas in
src/server/validation.ts (or inline in the route file). app.onError maps
ValidationFailed to Inertia 422 payloads.
const postBody = t.Object( { title: t.String({ minLength: 1 }), body: t.String({ minLength: 1 }) }, { additionalProperties: false },);additionalProperties: false keeps strict-by-default behavior. The email
string format is pre-registered in validation.ts; add other formats there.
4. Add the page component
Section titled “4. Add the page component”Create src/client/pages/Posts.tsx:
export default function Posts({ posts }: { posts: Post[] }) { return ( <ul> {posts.map((p) => <li key={p.id}>{p.title}</li>)} </ul> );}Register it in src/client/pages.ts (explicit imports, no auto-discovery):
import Posts from "./pages/Posts";export const pages = { /* … */, Posts };Co-locate styles in a sibling Posts.css imported by the component — never
add page-specific rules to styles.css (global base only).
5. Add a migration (if needed)
Section titled “5. Add a migration (if needed)”If the feature needs a new table, add a numbered SQL file:
CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')));Apply locally:
bun run db:migrateNever edit an applied migration — always add a new numbered file.
6. Verify
Section titled “6. Verify”bun run build # client bundlebun run typecheck # tsc --noEmitbun run test # bun test --isolatebun run dev # smoke-test in the browserFollow AGENTS.md throughout — it exists to keep new code structurally
consistent with the existing architecture.