TurboJet
API

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:

packages/validations/src/feedback/feedback.ts
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:

packages/api/src/modules/feedback/service.ts
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.

packages/api/src/modules/feedback/router.ts
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:

IntentTypical shape
ListGET /
CreatePOST /
Find oneGET /:id
UpdatePATCH /:id or PUT /:id
DeleteDELETE /:id

These are ordinary Hono routes, not separate oRPC procedure files.

4. Mount the router

Register the module on the app router:

packages/api/src/index.ts
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.

On this page