Define an API endpoint
Zod schemas, services, Hono routers, mounting, and optional OpenAPI paths.
This walkthrough uses the feedback module as the concrete example. The same steps apply to any domain under packages/api/src/modules/.
For the full product slice (UI + mutation + i18n), see Build a feature.
1. Add a Zod schema
Put input schemas in packages/validations/src/{domain}/ and export them from the package:
import { z } from "zod";
export const feedbackTypeSchema = z.enum(["bug", "feature", "general"]);
export const createFeedbackSchema = z.object({
message: z
.string({ message: "Please enter your feedback" })
.trim()
.min(10, "Feedback must be at least 10 characters")
.max(1000, "Feedback must be at most 1000 characters"),
type: feedbackTypeSchema,
email: z
.union([z.email({ error: "Invalid email address" }), z.literal("")])
.optional(),
});
export type CreateFeedbackData = z.infer<typeof createFeedbackSchema>;2. Implement the service
Keep business logic and database access in the service — not in the router:
import { db } from "@repo/database";
import type { CreateFeedbackData } from "@repo/validations/feedback";
import type { SafeSession } from "@repo/validations/user";
import { fail } from "../../lib/http";
export async function createFeedback({
data,
session,
ipAddress,
}: {
data: CreateFeedbackData;
session: SafeSession | null;
ipAddress?: string | null;
}) {
const email = session?.user.email ?? (data.email || null);
if (!session && !email) {
fail(400, "Email is required when you are not signed in");
}
const feedback = await db.feedback.create({
data: {
message: data.message,
type: data.type,
email,
userId: session?.user.id ?? null,
ipAddress: ipAddress ?? null,
},
select: { id: true, type: true, createdAt: true },
});
return {
success: true as const,
feedback: {
id: feedback.id,
type: feedback.type,
createdAt: feedback.createdAt.toISOString(),
},
};
}Use fail(status, message) from packages/api/src/lib/http.ts for typed HTTP errors.
3. Define the Hono router
Middleware order matters: rate limit → auth (if any) → zValidator → handler.
import { Hono } from "hono";
import { createFeedbackSchema } from "@repo/validations/feedback";
import { zValidator } from "../../lib/validator";
import { rateLimit } from "../../middleware/rate-limit";
import { getSessionFromHeaders } from "../../lib/session";
import { createFeedback } from "./service";
export const feedbackRouter = new Hono().post(
"/",
rateLimit("WRITE"),
zValidator("json", createFeedbackSchema),
async (c) => {
const session = await getSessionFromHeaders(c.req.raw.headers);
const result = await createFeedback({
data: c.req.valid("json"),
session,
ipAddress: c.req.header("x-forwarded-for") ?? null,
});
return c.json(result);
},
);Authenticated handlers use requireAuth and read c.get("session") — see Protect endpoints.
Naming guidance
Prefer clear Hono methods and path segments over one giant handler:
| Intent | Typical shape |
|---|---|
| List | GET / |
| Create | POST / |
| Find one | GET /:id |
| Update | PATCH /:id or PUT /:id |
| Delete | DELETE /:id |
These are ordinary Hono routes, not separate oRPC procedure files.
4. Mount the router
Register the module on the app router:
import { feedbackRouter } from "./modules/feedback/router";
export const routes = new Hono()
.route("/me", meRouter)
// …
.route("/feedback", feedbackRouter);The marketing catch-all mounts the full app at /api, so this becomes POST /api/feedback.
5. Optional: OpenAPI / Scalar
If you want the path in Scalar, add it to the hand-written document in packages/api/src/openapi.ts. OpenAPI is not generated from routers automatically.