Development standards and patterns for building APIs with the Nerva framework.
All projects use strict: true in tsconfig.json. No exceptions.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}Never use any. Use unknown when the type is genuinely not known, then narrow with type guards:
// Bad
function parseBody(body: any) { /* ... */ }
// Good
function parseBody(body: unknown): ParsedBody {
const parsed = bodySchema.parse(body);
return parsed;
}All external input (request bodies, query params, headers) must be validated with Zod schemas:
import { z } from "zod";
export const createTodoSchema = z.object({
title: z.string().min(1).max(255),
completed: z.boolean().default(false),
});
export type CreateTodoInput = z.infer<typeof createTodoSchema>;Derive types from Drizzle schemas rather than duplicating definitions:
import { todos } from "../db/schema/todos";
import { InferSelectModel, InferInsertModel } from "drizzle-orm";
export type Todo = InferSelectModel<typeof todos>;
export type NewTodo = InferInsertModel<typeof todos>;Group routes by resource using Hono.route():
import { Hono } from "hono";
import { todosRoutes } from "./routes/todos";
import { usersRoutes } from "./routes/users";
const app = new Hono();
app.route("/api/v1/todos", todosRoutes);
app.route("/api/v1/users", usersRoutes);Stack middleware in order -- auth before validation before handler:
import { zValidator } from "@hono/zod-validator";
import { jwt } from "hono/jwt";
const todosRoutes = new Hono();
todosRoutes.post(
"/",
jwt({ secret: env.JWT_SECRET }),
zValidator("json", createTodoSchema),
async (c) => {
const input = c.req.valid("json");
const todo = await todoService.create(input);
return c.json(todo, 201);
}
);Throw typed errors in handlers and let a single global handler serialize them to the standard error envelope:
import { NotFoundError, errorHandler } from "../middleware/error-handler";
// In route handlers -- throw typed errors
if (!todo) {
throw new NotFoundError("Todo", id);
}
// Global error handler in app setup -- serializes AppError, ZodError,
// HTTPException, and unexpected errors to the standard envelope
app.onError(errorHandler);Type your Hono app with environment bindings:
type Bindings = {
DB: D1Database;
JWT_SECRET: string;
};
type Variables = {
user: { id: string; role: string };
};
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();One file per table in api/src/db/schema/. Export all tables from an index.ts barrel:
// api/src/db/schema/todos.ts
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const todos = pgTable("todos", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
completed: boolean("completed").notNull().default(false),
userId: uuid("user_id").references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});Define relations explicitly for type-safe joins:
import { relations } from "drizzle-orm";
export const todosRelations = relations(todos, ({ one }) => ({
user: one(users, {
fields: [todos.userId],
references: [users.id],
}),
}));Use the query builder for reads, insert/update/delete for writes:
// Read with relations
const todosWithUser = await db.query.todos.findMany({
where: eq(todos.completed, false),
with: { user: true },
limit: 20,
offset: 0,
});
// Insert
const [newTodo] = await db.insert(todos).values(input).returning();
// Update
const [updated] = await db
.update(todos)
.set({ completed: true, updatedAt: new Date() })
.where(eq(todos.id, id))
.returning();Wrap multi-table writes in transactions:
const result = await db.transaction(async (tx) => {
const [todo] = await tx.insert(todos).values(input).returning();
await tx.insert(activityLog).values({
action: "todo.created",
resourceId: todo.id,
});
return todo;
});Test business logic in isolation with mocked database:
// api/tests/unit/todo-service.test.ts
describe("TodoService", () => {
it("should reject empty titles", () => {
expect(() => todoService.validate({ title: "" })).toThrow();
});
});Test the full request/response cycle with a real test database:
// api/tests/integration/todos.test.ts
import { app } from "../../src/app";
describe("POST /api/v1/todos", () => {
it("should create a todo and return 201", async () => {
const res = await app.request("/api/v1/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test todo" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.title).toBe("Test todo");
expect(body.id).toBeDefined();
});
});Validate responses match the OpenAPI spec:
import { validateResponse } from "../helpers/contract";
it("GET /todos response matches OpenAPI schema", async () => {
const res = await app.request("/api/v1/todos");
await validateResponse(res, "get", "/todos", 200);
});k6 scripts in api/tests/load/:
import http from "k6/http";
import { check } from "k6";
export const options = { vus: 50, duration: "30s" };
export default function () {
const res = http.get("http://localhost:8787/api/v1/todos");
check(res, { "status is 200": (r) => r.status === 200 });
}Every route that accepts input must validate with Zod before processing. Never trust client data.
- JWT -- Stateless, preferred for Cloudflare Workers (no session storage needed)
- API Keys -- For service-to-service communication, stored hashed in database
- OAuth2 -- For third-party integrations, delegate to provider
Apply rate limits per route group:
import { rateLimiter } from "../middleware/rate-limit";
app.use("/api/v1/*", rateLimiter({ max: 100, window: "1m" }));
app.use("/api/v1/auth/*", rateLimiter({ max: 10, window: "1m" }));Configure CORS explicitly -- never leave origin: * in production:
import { cors } from "hono/cors";
app.use("*", cors({
origin: ["https://app.example.com"],
allowMethods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "Authorization"],
}));See the CORS configuration guide for environment-specific setups, credentials mode, preflight caching, and how to debug common CORS errors.
All error responses follow the same shape -- a single top-level error object:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "title", "message": "Required" }
]
}
}| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR |
400 | Request body or params failed validation |
UNAUTHORIZED |
401 | Missing or invalid authentication |
FORBIDDEN |
403 | Authenticated but insufficient permissions |
NOT_FOUND |
404 | Resource does not exist |
CONFLICT |
409 | Duplicate resource or state conflict |
PAYLOAD_TOO_LARGE |
413 | Request body exceeds the size limit |
RATE_LIMITED |
429 | Too many requests |
INTERNAL_ERROR |
500 | Unexpected server error |
See the error handling guide for the full format definition, error code constants, typed error classes, the global app.onError() handler, per-category examples, and how the format maps to RFC 9457 Problem Details.
- Always select only needed columns:
.select({ id: todos.id, title: todos.title }) - Use pagination with
limitandoffsetor cursor-based pagination for large datasets - Add indexes for columns used in
WHERE,ORDER BY, andJOINclauses
Use Cache-Control headers for GET endpoints. On Cloudflare Workers, leverage the Cache API:
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");For Node.js deployments, configure connection pooling in the Drizzle client:
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
const pool = new Pool({ connectionString: env.DATABASE_URL, max: 20 });
const db = drizzle(pool);| Element | Convention | Example |
|---|---|---|
| Route paths | kebab-case | /api/v1/user-profiles |
| File names | kebab-case | user-profile.ts |
| Schema/table names (TS) | camelCase variable, PascalCase type | userProfiles, UserProfile |
| Database columns | snake_case | created_at, user_id |
| Environment variables | SCREAMING_SNAKE | DATABASE_URL, JWT_SECRET |
| Zod schemas | camelCase with Schema suffix |
createTodoSchema |
| Service classes | PascalCase with Service suffix |
TodoService |
| Test files | Same as source with .test.ts |
todos.test.ts |