For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Build a production feature
Ship a complete feature from database to UI. Uses a feedback widget walkthrough across database, API, types, and UI.
Most features follow the same path: persist data, expose an API, build UI, translate strings, and ship. TurboStarter wires those layers together with shared types end to end, so you never re-declare the same shape three times.
In this recipe you'll build a feedback widget: a floating button that opens a dialog, collects a message, and stores it in Postgres. It's small enough to finish in one sitting, but large enough to touch every layer you'll use for bigger features like support tickets, feature requests, or in-app surveys.
What you'll ship
A production-ready feedback flow:
- Drizzle table with optional link to the signed-in user
- Protected Hono
POST /api/feedbackendpoint with Zod validation - React form wired through TanStack Query and the typed API client
- English copy in
packages/i18n(extend to more locales the same way) - Floating widget on marketing pages
Architecture
Data flows from the UI module (FeedbackWidget) through modules/feedback/lib/api.ts, into the Hono router at POST /api/feedback, and finally into the Drizzle table in packages/db. The typed hc<AppRouter> client keeps the contract aligned between client and server.
TurboStarter already ships full-stack examples you can peek at while you build:
Organizations
Custom Hono reads + Better Auth mutations, data tables, org-scoped access.
Billing
Custom tables, webhooks, and plan-aware API routes.
Storage
Small protected router. Good template for upload-style endpoints.
Add the database table
Create a dedicated schema file and export it from the barrel.
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { generateId } from "@workspace/shared/utils";
import { createInsertSchema } from "../lib/zod";
import { user } from "./auth";
export const feedbackTypeEnum = pgEnum("feedback_type", [
"general",
"bug",
"feature",
]);
export const feedback = pgTable("feedback", {
id: text("id").primaryKey().$defaultFn(generateId),
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
message: text("message").notNull(),
type: feedbackTypeEnum("type").notNull().default("general"),
email: text("email"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const feedbackRelations = relations(feedback, ({ one }) => ({
user: one(user, {
fields: [feedback.userId],
references: [user.id],
}),
}));
export const insertFeedbackSchema = createInsertSchema(feedback, {
message: (schema) => schema.min(10).max(1000),
email: (schema) => schema.email().optional(),
});export * from "./auth";
export * from "./billing";
export * from "./feedback";Generate and apply the migration:
pnpm with-env turbo db:generate
pnpm with-env pnpm --filter @workspace/db db:migrateNeed a refresher on Drizzle workflows? See Migrations and Database overview.
Expose the API
Request & response schemas
Keep API contracts in packages/api so every app imports the same Zod types.
import * as z from "zod";
export const createFeedbackInputSchema = z.object({
message: z.string().min(10).max(1000),
type: z.enum(["general", "bug", "feature"]).default("general"),
email: z.string().email().optional(),
});
export const createFeedbackResponseSchema = z.object({
id: z.string(),
});
export type CreateFeedbackInput = z.infer<typeof createFeedbackInputSchema>;
export type CreateFeedbackResponse = z.infer<
typeof createFeedbackResponseSchema
>;Export from packages/api/src/schema/index.ts.
Query function
import { db } from "@workspace/db/server";
import { feedback } from "@workspace/db/schema";
import type { CreateFeedbackInput } from "../../schema/feedback";
export const createFeedback = async ({
userId,
...input
}: CreateFeedbackInput & { userId?: string }) => {
const [row] = await db
.insert(feedback)
.values({
...input,
userId,
})
.returning({ id: feedback.id });
return row;
};Router
Feedback can be submitted by guests or signed-in users. Use enforceAuth only when you need a session. Here we read the session inside the handler and attach userId when present.
import { Hono } from "hono";
import { auth } from "@workspace/auth/server";
import { enforceAuth, validate } from "../../middleware";
import {
createFeedbackInputSchema,
createFeedbackResponseSchema,
} from "../../schema/feedback";
import { createFeedback } from "./queries";
export const feedbackRouter = new Hono().post(
"/",
validate("json", createFeedbackInputSchema),
async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
const input = c.req.valid("json");
const row = await createFeedback({
...input,
userId: session?.user.id,
// Guests can pass email; signed-in users don't need to
email: session?.user.email ?? input.email,
});
return c.json(createFeedbackResponseSchema.parse(row));
},
);Register the router next to the other modules:
import { feedbackRouter } from "./modules/feedback/router";
const appRouter = new Hono()
.basePath("/api")
// ...existing routers
.route("/feedback", feedbackRouter);Protection levels
| Endpoint | Middleware |
|---|---|
| Public read (blog posts) | validate only |
| User-specific write | enforceAuth |
| Org-scoped resource | enforceAuth + enforceMembership |
See Protected routes and Adding new endpoint for the full menu.
Wire the web client
Each feature gets a lib/api.ts beside its UI. Same pattern as organizations and billing.
import { mutationOptions } from "@tanstack/react-query";
import { createFeedbackResponseSchema } from "@workspace/api/schema";
import { handle } from "@workspace/api/utils";
import { api } from "~/lib/api/client";
const KEY = "feedback";
export const feedback = {
mutations: {
create: mutationOptions({
mutationKey: [KEY, "create"],
mutationFn: (json: { message: string; type: string; email?: string }) =>
handle(api.feedback.$post, {
schema: createFeedbackResponseSchema,
})({ json }),
}),
},
};The handle() helper parses the response with your Zod schema. If the API shape drifts, TypeScript and runtime validation both complain early.
Build the UI
TurboStarter forms are built in a familiar, easy-to-follow way: you use a form library to manage the form, a helper to check the input, and ready-made pieces for the form fields. This keeps your forms clear and consistent.
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { Controller, useForm } from "react-hook-form";
import { createFeedbackInputSchema } from "@workspace/api/schema";
import { useTranslation } from "@workspace/i18n";
import { Button } from "@workspace/ui-web/button";
import { Field, FieldError, FieldLabel } from "@workspace/ui-web/field";
import { Icons } from "@workspace/ui-web/icons";
import { Input } from "@workspace/ui-web/input";
import {
Modal,
ModalBody,
ModalClose,
ModalContent,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTrigger,
} from "@workspace/ui-web/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui-web/select";
import { Textarea } from "@workspace/ui-web/textarea";
import { toast } from "sonner";
import { authClient } from "~/lib/auth/client";
import { feedback } from "./lib/api";
import type { CreateFeedbackInput } from "@workspace/api/schema";
export const FeedbackWidget = () => {
const { t } = useTranslation(["common", "feedback"]);
const session = authClient.useSession();
const user = session.data?.user;
const create = useMutation({
...feedback.mutations.create,
onSuccess: () => {
form.reset();
toast.success(t("feedback:success"));
},
onError: () => toast.error(t("feedback:error")),
});
const form = useForm<CreateFeedbackInput>({
resolver: standardSchemaResolver(createFeedbackInputSchema),
defaultValues: { message: "", type: "general", email: "" },
});
return (
<Modal>
<ModalTrigger
render={
<Button
variant="outline"
size="sm"
className="fixed right-4 bottom-4 z-50 shadow-lg"
>
<Icons.MessageCircle className="mr-2 size-4" />
{t("feedback:button")}
</Button>
}
/>
<ModalContent className="sm:max-w-md">
<ModalHeader>
<ModalTitle>{t("feedback:title")}</ModalTitle>
</ModalHeader>
<ModalBody>
<form
id="feedback-form"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit((data) => create.mutate(data))}
>
<Controller
name="type"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>{t("feedback:type.label")}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="general">
{t("feedback:type.general")}
</SelectItem>
<SelectItem value="bug">
{t("feedback:type.bug")}
</SelectItem>
<SelectItem value="feature">
{t("feedback:type.feature")}
</SelectItem>
</SelectContent>
</Select>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
{!user && (
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>{t("common:email")}</FieldLabel>
<Input {...field} type="email" />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
)}
<Controller
name="message"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>{t("feedback:message.label")}</FieldLabel>
<Textarea {...field} className="min-h-24" />
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</form>
</ModalBody>
<ModalFooter>
<ModalClose
render={
<Button variant="outline" type="button">
{t("common:cancel")}
</Button>
}
/>
<Button
type="submit"
form="feedback-form"
disabled={create.isPending}
>
{t("feedback:submit")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};Drop the widget into a layout that should expose it globally:
import { FeedbackWidget } from "~/modules/feedback/feedback-widget";
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<FeedbackWidget />
</>
);
}Add translations
Add a namespace file per feature under packages/i18n/src/translations/en/.
{
"button": "Feedback",
"title": "Send feedback",
"submit": "Send",
"success": "Thanks! We got your message.",
"error": "Something went wrong. Try again.",
"type": {
"label": "Type",
"general": "General",
"bug": "Bug report",
"feature": "Feature request"
},
"message": {
"label": "Message"
}
}Register the namespace in the i18n config if your project requires explicit listing (follow the pattern used by organization.json).
API validation errors can be localized too. The validate middleware maps Zod issues through makeZodI18nMap so form errors stay consistent with server responses.
Verify & iterate
Manual smoke test
- Start services:
pnpm services:setupthenpnpm dev - Open a marketing page. The floating button should appear.
- Submit as a guest (email required) and as a signed-in user (email hidden)
- Confirm rows in Postgres:
select * from feedback;
Optional: admin inbox
Add a GET /api/feedback route behind enforceAuth + enforceAdmin, then a page under apps/web/src/app/[locale]/admin/. The admin module is a ready-made shell for internal tools.
Optional: email on submit
Trigger an email template from the mutation handler or a background job. See Emails.
File structure
Checklist
| Layer | Done when… |
|---|---|
| Database | Migration applied; table visible in Postgres |
| API | POST /api/feedback returns { id } with valid body |
| Types | AppRouter includes /feedback. Client autocomplete works. |
| UI | Form validates, shows loading state, toasts on success/error |
| i18n | All user-visible strings use t() |
| Auth | Guest vs signed-in behavior matches your product rules |
Next steps
Build a production feature (mobile)
Same API, native Bottom Sheet UI.
Build a production feature (extension)
Lightweight trigger in the popup.
Feature-based access
Restrict feedback to paying users. Uses the same API and database table.
E2E testing
Lock the flow with Playwright. Uses the same API and database table.
The feedback widget is deliberately small. The same file layout and data flow scale to organizations-sized features. You're learning the shape TurboStarter expects, not a one-off pattern.
How is this guide?
Last updated on