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

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 feedback mutations in modules/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

ConcernExtension
API clientx-client-platform: EXTENSION. No cookie jar like web.
Auth stateauthClient.useSession() / useActiveOrganization()
Full CRUD UIUsually deferred to web (appConfig.url + paths)
Entry pointspopup, sidepanel, options, newtab. Each mounts separately.
React QueryWrap each entry in QueryClientProvider via shared Layout

API client setup

The extension client is minimal. Platform header only:

apps/extension/src/lib/api/index.tsx
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:

apps/extension/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";

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.

apps/extension/src/modules/feedback/feedback-form.tsx
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:

apps/extension/src/app/popup/main.tsx
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.

Extensions excel at capture; web excels at management. Pattern from billing and organizations:

apps/extension/src/modules/feedback/feedback-footer.tsx
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

  1. Run the web API: pnpm dev (extension calls appConfig.url)
  2. Build / load the extension: pnpm --filter extension dev
  3. Open the popup, submit feedback, verify the database row
  4. 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

feedback-form.tsx - Compact popup form
feedback-footer.tsx - Link to web dashboard
api.ts - Mutations and query keys
main.tsx - Mounts Layout + FeedbackForm

Ship checklist

LayerDone when…
LayoutPopup wrapped in QueryClientProvider via Layout
APIMutation succeeds with EXTENSION platform header
SessionSigned-in users skip guest-only fields
UXToast on success; form resets for another submission
Deep linkOptional web URL opens for admin/history (when built)

When to go extension-native vs web-only

Build in extensionKeep on web
Quick capture (feedback, bookmark, note)Data tables, filters, bulk actions
Session / plan badgeBilling checkout and portal
Theme + locale togglesOrganization settings and RBAC
Open-current-tab helpersFile uploads with large previews

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter