For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Build a production feature
Surface your feature inside the browser extension. Session-aware API calls, popup UI, and deep links back to the web app.
Extensions are not a third backend. They are another client for the same Hono API, optimized for quick actions in the popup, side panel, or toolbar. Full management UI usually lives on the web when the surface is too cramped.
This recipe adds a feedback entry point to the extension: read the user's session, submit feedback through POST /api/feedback, and link out to the dashboard for anything heavier (admin inbox, attachments, long threads).
Complete the shared backend in the web feature recipe first.
What you'll ship in the extension
- Typed
feedbackmutations inmodules/feedback/lib/api.ts - A compact form in the popup (or side panel)
- Session-aware UI. Hide email when
authClient.useSession()returns a user. - Optional link to the web app for admin / history views
Where extensions fit
The popup mounts FeedbackForm, which reads the Better Auth session and calls lib/api.ts. Requests hit POST /api/feedback with the EXTENSION platform header. For admin or history views, link out to the web dashboard.
TurboStarter's extension already demonstrates session and API usage in the header and billing badge. Study apps/extension/src/modules/common/layout/header.tsx and modules/billing/lib/api.ts for production patterns.
Extension vs web vs mobile
| Concern | Extension |
|---|---|
| API client | x-client-platform: EXTENSION. No cookie jar like web. |
| Auth state | authClient.useSession() / useActiveOrganization() |
| Full CRUD UI | Usually deferred to web (appConfig.url + paths) |
| Entry points | popup, sidepanel, options, newtab. Each mounts separately. |
| React Query | Wrap each entry in QueryClientProvider via shared Layout |
API client setup
The extension client is minimal. Platform header only:
export const { api } = hc<AppRouter>(getBaseUrl(), {
headers: {
"x-client-platform": Platform.EXTENSION,
},
});Session cookies are handled by Better Auth's extension integration (same authClient as other surfaces). If protected routes return 401, confirm the user completed extension auth. See Auth overview.
Feature API module
Identical shape to web and mobile. Only the import path for api differs:
import { mutationOptions } from "@tanstack/react-query";
import { createFeedbackResponseSchema } from "@workspace/api/schema";
import { handle } from "@workspace/api/utils";
import { api } from "~/lib/api";
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 }),
}),
},
};Build a compact popup form
Extension UI uses @workspace/ui-web (same primitives as the web app) inside a narrow viewport. Keep fields minimal. Message and type are enough for v1.
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 { Textarea } from "@workspace/ui-web/textarea";
import { toast } from "sonner";
import { authClient } from "~/lib/auth";
import { feedback } from "./lib/api";
import type { CreateFeedbackInput } from "@workspace/api/schema";
export const FeedbackForm = () => {
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" },
});
if (session.isPending) {
return null;
}
return (
<form
className="flex w-80 flex-col gap-3 p-4"
onSubmit={form.handleSubmit((data) =>
create.mutate({
...data,
email: user ? undefined : data.email,
}),
)}
>
<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-20" />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="submit" size="sm" disabled={create.isPending}>
{t("feedback:submit")}
</Button>
</form>
);
};Mount in an entry point
Every extension surface is its own React root. Wrap with the shared Layout so React Query and error boundaries are available:
import { Layout } from "~/modules/common/layout/layout";
import { Header } from "~/modules/common/layout/header";
import { FeedbackForm } from "~/modules/feedback/feedback-form";
export default function Popup() {
return (
<Layout>
<Header />
<FeedbackForm />
</Layout>
);
}The same FeedbackForm can be imported into sidepanel/main.tsx or tucked behind a tab in options if you want a settings-style layout.
Link out for the heavy UI
Extensions excel at capture; web excels at management. Pattern from billing and organizations:
import { useTranslation } from "@workspace/i18n";
import { Button } from "@workspace/ui-web/button";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
export const FeedbackFooter = () => {
const { t } = useTranslation("feedback");
return (
<Button
variant="link"
size="sm"
onClick={() => {
void chrome.tabs.create({
url: new URL(pathsConfig.dashboard.index, appConfig.url).href,
});
}}
>
{t("viewInDashboard")}
</Button>
);
};Add viewInDashboard to feedback.json when you build the admin inbox on web.
Test the extension
Local dev
- Run the web API:
pnpm dev(extension callsappConfig.url) - Build / load the extension:
pnpm --filter extension dev - Open the popup, submit feedback, verify the database row
- Test signed-out behavior. Validation errors should surface in the popup.
Playwright E2E (optional)
Extension E2E lives under apps/extension/e2e/. Reuse auth storage state and assert the popup form. See E2E testing.
File map
Ship checklist
| Layer | Done when… |
|---|---|
| Layout | Popup wrapped in QueryClientProvider via Layout |
| API | Mutation succeeds with EXTENSION platform header |
| Session | Signed-in users skip guest-only fields |
| UX | Toast on success; form resets for another submission |
| Deep link | Optional web URL opens for admin/history (when built) |
When to go extension-native vs web-only
| Build in extension | Keep on web |
|---|---|
| Quick capture (feedback, bookmark, note) | Data tables, filters, bulk actions |
| Session / plan badge | Billing checkout and portal |
| Theme + locale toggles | Organization settings and RBAC |
| Open-current-tab helpers | File uploads with large previews |
Related guides
How is this guide?
Last updated on