TurboJet
Recipes

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:generate

2. 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 /> in LandingLayout (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.apiDocs is on)
  • pnpm check / pnpm lint pass
  • API — Hono + Query conventions
  • Setup — local env and ports

On this page