For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Onboarding flow
Add a first-run experience to the TurboStarter browser extension - welcome state, web auth, and optional plan-gated empty screens.
Browser extensions should stay thin. TurboStarter already authenticates the extension against the web Better Auth session and shows plan badges from the same billing API. There is no built-in multi-step wizard in the extension - and you usually should not build a full one inside the popup.
This recipe covers a practical first-run path: welcome → sign in on the web → optional hard empty-state until the user has a paid plan.
TL;DR
- Detect first open with
chrome.storage/browser.storage(or skip if session exists). - Send unauthenticated users to the web
/auth/login(existing pattern). - Prefer completing rich onboarding on web; sync completion via the API/session.
- Soft monetization: show upgrade links to web pricing. Hard: block premium UI until
getActivePlan()is paid. - Never trust the extension alone - enforce plans on the API.
Web vs extension responsibilities
| Concern | Web app | Extension |
|---|---|---|
| Multi-step profile / workspace wizard | Yes - web onboarding | Link out or skip |
| Auth | Full Better Auth UI | Opens web login |
| Checkout | Stripe / Lemon / Polar / … | Link to web pricing or portal |
| First-run chrome | Dashboard onboarding route | Popup / options empty state |
| Source of truth | Postgres + session | API summary + storage flags |
If you already require onboarding completion on the web, the extension only needs to handle logged-out and logged-in-but-unpaid states.
When the popup opens:
- No session - show a short welcome and a Sign in button that opens web
/auth/login. - Session + soft mode - render the normal popup / sidepanel.
- Session + hard mode + Free plan - show a “Choose a plan” empty state that links to web
/pricingor/dashboard/choose-plan.
Add a first-run flag (optional)
Use extension storage for UI that should show once per install (tooltips, permission explanations). Do not use it as the only “onboarding completed” signal if the same user might finish setup on the web first.
const KEY = "extension.firstRunSeen";
export const getFirstRunSeen = async () => {
const result = await chrome.storage.local.get(KEY);
return Boolean(result[KEY]);
};
export const setFirstRunSeen = async () => {
await chrome.storage.local.set({ [KEY]: true });
};Show a short welcome card in the popup when !session && !firstRunSeen, then set the flag when they dismiss or click Sign in.
Reuse web auth
The extension auth client already talks to the web app. Login UX should open the web login page (same as the user menu / e2e flows):
const loginUrl = `${webAppUrl}/auth/login`;
// chrome.tabs.create({ url: loginUrl }) or window.open in the popupAfter login, the session cookie / token flow used by apps/extension/src/lib/auth hydrates authClient.useSession(). Prefer redirecting new users to web onboarding when you need profile data:
const loginUrl = `${webAppUrl}/auth/login?redirectTo=/dashboard/onboarding`;Only build in-extension forms when the answer is extension-specific (e.g. “which sites to enable”).
Soft empty state for logged-out users
Keep the popup useful even before auth: brand, one sentence, primary CTA.
import { Button } from /* your extension UI */;
interface WelcomeProps {
readonly onSignIn: () => void;
}
export const Welcome = ({ onSignIn }: WelcomeProps) => {
return (
<div className="flex flex-col gap-3 p-4">
<h1 className="text-base font-semibold">Welcome to TurboStarter</h1>
<p className="text-muted-foreground text-sm">
Sign in to sync your account and unlock extension features.
</p>
<Button onClick={onSignIn}>Sign in</Button>
</div>
);
};Render Welcome when !session; otherwise render the normal popup content (header already shows the user when signed in).
Optional: hard paywall empty state
Extensions rarely run native IAP. “Hard paywall” means: hide product UI until the billing summary shows a paid plan, and send the user to the web to checkout.
interface ChoosePlanProps {
readonly pricingUrl: string;
}
export const ChoosePlanEmpty = ({ pricingUrl }: ChoosePlanProps) => {
return (
<div className="flex flex-col gap-3 p-4">
<h1 className="text-base font-semibold">Subscription required</h1>
<p className="text-muted-foreground text-sm">
Choose a plan on the web to use this extension.
</p>
<a href={pricingUrl} target="_blank" rel="noreferrer">
View plans
</a>
</div>
);
};Resolve the plan with the same summary query as feature-based access, then compose in the popup root:
import { BillingPlan, getActivePlan } from "@workspace/billing";
const summary = useQuery(billing.queries.summary.get(user.id));
const activePlan = getActivePlan(summary.data);
if (!session) {
return <Welcome onSignIn={openLogin} />;
}
if (REQUIRE_PAID_PLAN && activePlan === BillingPlan.FREE) {
return <ChoosePlanEmpty pricingUrl={pricingUrl} />;
}
return <PopupApp />;Point pricingUrl at marketing /pricing or your web choose-plan route from the web onboarding recipe.
Prefer feature gates when only some actions are paid - hard-blocking the whole popup is for paid-only products.
Keep content scripts honest
Content scripts and background workers can call your API with the user’s session. Treat them like any other client:
- Soft: degrade UI when Free.
- Hard: do not inject premium behaviors until the plan check passes.
- Always enforce on the server with the same middleware as web.
If onboarding answers live only on the web, fetch them via API when the extension needs them - do not re-collect in the popup.
Checklist
- Logged-out popup has a clear Sign in path to the web app
- Rich profile/workspace onboarding lives on web when possible
- First-run storage is UX-only, not the security boundary
- Hard mode links to web checkout; plan resolved with
getActivePlan(summary) - API enforces paid features (feature-based access)
Other platforms
How is this guide?
Last updated on