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.
Nerva versions APIs by URL prefix -- /api/v1/... -- configured in the api.versioning block of .claude/pipeline.config.json (strategy: "url-prefix", currentVersion: "v1"). Add endpoints and optional fields within a version; bump to /api/v2 only for breaking changes, and warn consumers first with Deprecation and Sunset headers.
See the API versioning guide for all four strategies (URL prefix, header, content negotiation, query parameter) and how to implement each in Hono, the deprecation and sunset headers, the breaking vs. non-breaking reference, and a migration checklist for version bumps.
- 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
See the database indexing guide for composite, partial, and covering indexes in Drizzle, when to choose B-tree vs. GIN vs. GiST, the indexing anti-patterns that slow APIs down, and how to validate index usage with EXPLAIN ANALYZE.
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 |