For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Feature-based access
Gate TurboStarter browser extension features by subscription plan. Read billing summary from the API, hide UI, and link users to web checkout.
Browser extensions sit between a lightweight UI and your full web app. Customers expect premium extension features to match what they purchased on the web — without exposing billing secrets in the extension bundle.
TurboStarter extensions call the same billing API as the web dashboard. This recipe shows how to resolve the active plan, gate extension UI, and send users to the web app when they need to upgrade.
TL;DR
- Reuse feature keys from
packages/billing/shared/src/config/features.ts. - Fetch
billing.queries.summarywith the user or organizationreferenceId. - Call
getActivePlan()andisFeatureAvailable()— same helpers as web. - Hide or disable locked UI; open the web dashboard for checkout.
- Enforce access on API routes — the extension is not a trust boundary.
What is different in the extension?
| Concern | Web app | Extension |
|---|---|---|
| Billing checkout | In-app Stripe / portal | Link to web pricing or portal |
| State | Full React Query cache | Thin summary query |
| Entitlements | DB subscriptions | API summary (no local store SDK) |
| Upgrade UX | Modal or /pricing | target="_blank" to web dashboard |
Extensions do not run RevenueCat or Superwall. If a user subscribes on mobile, webhooks sync to your database and the extension sees the updated plan on the next summary fetch.
For shared concepts — features.ts, billing config, isFeatureAvailable(), and API middleware — start with the web recipe.
Fetch billing summary from the API
The extension billing client mirrors web queries:
const queries = {
summary: {
get: (referenceId: string) =>
queryOptions({
queryKey: [KEY, "summary", referenceId],
queryFn: () =>
handle(api.billing.summary.$get)({
query: { referenceId },
}),
}),
},
};Use the authenticated user's id, or the active organization's id when the extension is organization-aware:
const summary = useQuery({
...billing.queries.summary.get(organization?.id ?? user?.id ?? ""),
enabled: !!organization || !!user,
});
const activePlan = getActivePlan(summary.data);The user navigation component already displays the resolved plan badge — reuse that referenceId everywhere you gate features.
Add a reusable access hook
Centralize plan logic so popup, options page, and content scripts stay consistent:
import { useQuery } from "@tanstack/react-query";
import {
getActivePlan,
isFeatureAvailable,
getHigherPlans,
} from "@workspace/billing";
import { billing } from "~/modules/billing/lib/api";
import type { Feature } from "@workspace/billing";
export const useBillingAccess = (referenceId: string | undefined) => {
const summary = useQuery({
...billing.queries.summary.get(referenceId ?? ""),
enabled: !!referenceId,
});
const activePlan = getActivePlan(summary.data);
const hasFeature = (feature: Feature) =>
isFeatureAvailable(summary.data ?? [], feature);
const nextUpgradePlan = getHigherPlans(activePlan)[0];
return {
activePlan,
hasFeature,
nextUpgradePlan,
isLoading: summary.isLoading,
isError: summary.isError,
};
};Gate extension UI
Popup actions
Disable premium actions and explain why:
import { FEATURES, BillingPlan } from "@workspace/billing";
import { Button } from "@workspace/ui-web/button";
import { appConfig } from "~/config/app";
import { useBillingAccess } from "~/modules/billing/hooks/use-billing-access";
export const PremiumAction = ({ userId }: { userId: string }) => {
const { hasFeature, isLoading } = useBillingAccess(userId);
const canExport = hasFeature(FEATURES[BillingPlan.PREMIUM].ADVANCED_REPORTS);
if (isLoading) {
return <Button disabled>Loading…</Button>;
}
if (!canExport) {
return (
<Button
variant="outline"
onClick={() => {
window.open(`${appConfig.url}/pricing`, "_blank");
}}
>
Upgrade to export
</Button>
);
}
return <Button onClick={runExport}>Export</Button>;
};Content scripts
If a content script needs plan data, pass it from the background service worker via chrome.runtime.sendMessage after fetching summary once — avoid duplicating auth cookies in injected scripts.
Options page
Show FeaturesList for the current plan and compare with getHigherPlans() so users know what an upgrade unlocks:
import { FeaturesList } from "~/modules/billing/features-list";
<FeaturesList planId={activePlan} />;Link to web checkout and portal
Extensions cannot host Stripe Checkout inline. Send users to the web app:
const pricingUrl = new URL("/pricing", appConfig.url);
pricingUrl.searchParams.set("redirectTo", chrome.runtime.getURL("popup.html"));
window.open(pricingUrl.toString(), "_blank");For existing subscribers, link to dashboard billing settings where the web app opens the provider portal:
const billingUrl = `${appConfig.url}/dashboard/settings/billing`;
window.open(billingUrl, "_blank");Match appConfig.url to your deployed web origin in extension environment config.
Handle unauthenticated users
The extension already shows a login button when user is null:
if (!user) {
return <AnonymousUser />;
}Treat unauthenticated the same as "feature locked" for premium actions — prompt login first, then evaluate plan:
if (!user) {
return (
<Button
onClick={() => window.open(`${appConfig.url}/auth/login`, "_blank")}
>
Sign in to continue
</Button>
);
}Enforce on the API
Extension UI gating improves UX but is not security. Any API route the extension calls must use enforceAuth and, where needed, enforceFeatureAvailable() — see the web recipe.
When the API returns 402 with error.upgradeRequired, map it in the extension client:
try {
await api.feature.export.$post();
} catch (error) {
if (error.code === "error.upgradeRequired") {
window.open(`${appConfig.url}/pricing`, "_blank");
return;
}
throw error;
}Cache and refresh strategy
Billing state changes after checkout completes in another tab:
- Set a modest
staleTimeon the summary query (e.g. 30–60 seconds). - Refetch when the popup opens (
refetchOnMount: "always"). - Listen for
chrome.storageevents if the background worker detects a successful web session change.
After the user upgrades on the web, reopening the popup should show unlocked features without reinstalling the extension.
Architecture sketch
User opens extension popup
│
▼
Session valid? ──no──► Login link → web auth
│
yes
▼
GET /billing/summary?referenceId=...
│
▼
getActivePlan(summary)
│
▼
isFeatureAvailable(summary, FEATURE)
│
┌────┴────┐
yes no
│ │
Feature Upgrade link
UI → web /pricingChecklist
useBillingAccessused in popup, options, and background workerreferenceIdmatches user or organization context- Upgrade links point to production web
appConfig.url - API routes enforce features server-side
- Summary refetches when popup opens
- Login flow tested for anonymous users
How is this guide?
Last updated on