Build a feature
End-to-end recipe for shipping Feedback — Prisma, Zod, Hono, TanStack Query, and UI.
This guide walks through the Feedback feature already in this boilerplate. Use it as a checklist whenever you add a new vertical slice.
What you get
- Guests can submit feedback from the marketing site (email required)
- Data lives in Postgres via Prisma
- Validation is shared through
@repo/validations - HTTP is Hono in
@repo/api(no Server Actions) - The UI calls the API with the typed Hono client + TanStack Query
1. Schema
Add a model in packages/database/prisma/schema.prisma:
enum FeedbackType {
bug
feature
general
}
model Feedback {
id String @id @default(cuid())
userId String?
email String?
message String
type FeedbackType @default(general)
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@map("feedback")
}Then migrate and regenerate the client:
pnpm --filter @repo/database db:migrate
pnpm --filter @repo/database db:generate2. Shared validation
Create packages/validations/src/feedback/feedback.ts and export it from packages/validations/package.json:
import { z } from "zod";
export const feedbackTypeSchema = z.enum(["bug", "feature", "general"]);
export const createFeedbackSchema = z.object({
message: z.string().trim().min(10).max(1000),
type: feedbackTypeSchema,
email: z.union([z.email(), z.literal("")]).optional(),
});
export const createGuestFeedbackSchema = createFeedbackSchema.extend({
email: z.email(),
});3. API module
Service
packages/api/src/modules/feedback/service.ts creates the row. Guests must send an email; authenticated users reuse the session email/user id.
Router
packages/api/src/modules/feedback/router.ts:
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);
},
);Session is optional — do not use requireAuth for public feedback.
Mount + CORS
Register the router in packages/api/src/index.ts and allow the marketing origin:
.route("/feedback", feedbackRouter)CORS uses NEXT_PUBLIC_MARKETING_URL / NEXT_PUBLIC_SAAS_URL with credentials. Export OPTIONS from apps/marketing/src/app/api/[[...route]]/route.ts.
4. Frontend mutation
Marketing (same origin)
apps/marketing/src/mutations/use-create-feedback.ts:
handle(api.feedback.$post({ json: data }));apps/marketing/src/lib/api.ts points the typed client at NEXT_PUBLIC_MARKETING_URL:
export const api = createApiClient(
`${clientEnv.NEXT_PUBLIC_MARKETING_URL}/api`,
);Wrap the marketing app in QueryClientProvider (see apps/marketing/src/app/providers.tsx).
5. UI widget
FeedbackWidget is a floating popover with type, optional email, and message:
- Marketing:
<FeedbackWidget />inLandingLayout(guest schema requires email)
Add i18n keys under feedback in packages/i18n/src/marketing/*.
Checklist
- Prisma model + migration
- Zod schemas exported from
@repo/validations - Hono service + router + mount
- CORS / OPTIONS if marketing calls SaaS
- TanStack Query mutation + typed
api.*call - Widget in the right layout
- EN + ID copy
- OpenAPI path (optional, when
features.apiDocsis on) -
pnpm check/pnpm lintpass