For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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/feedback endpoint 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:

Add the database table

Create a dedicated schema file and export it from the barrel.

packages/db/src/schema/feedback.ts
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(),
});
packages/db/src/schema/index.ts
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:migrate

Need 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.

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

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

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

packages/api/src/index.ts
import { feedbackRouter } from "./modules/feedback/router";

const appRouter = new Hono()
  .basePath("/api")
  // ...existing routers
  .route("/feedback", feedbackRouter);

Protection levels

EndpointMiddleware
Public read (blog posts)validate only
User-specific writeenforceAuth
Org-scoped resourceenforceAuth + 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.

apps/web/src/modules/feedback/lib/api.ts
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.

apps/web/src/modules/feedback/feedback-widget.tsx
"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:

apps/web/src/app/[locale]/(marketing)/layout.tsx
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/.

packages/i18n/src/translations/en/feedback.json
{
  "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

  1. Start services: pnpm services:setup then pnpm dev
  2. Open a marketing page. The floating button should appear.
  3. Submit as a guest (email required) and as a signed-in user (email hidden)
  4. 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

feedback.ts - feedback table + insert schema
router.ts - POST /api/feedback
queries.ts - Database writes
feedback.ts - Zod input/output schemas
feedback.json - Widget strings
feedback-widget.tsx - Floating button + modal form
api.ts - Mutations and query keys

Checklist

LayerDone when…
DatabaseMigration applied; table visible in Postgres
APIPOST /api/feedback returns { id } with valid body
TypesAppRouter includes /feedback. Client autocomplete works.
UIForm validates, shows loading state, toasts on success/error
i18nAll user-visible strings use t()
AuthGuest vs signed-in behavior matches your product rules

Next steps

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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter