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

  1. Reuse feature keys from packages/billing/shared/src/config/features.ts.
  2. Fetch billing.queries.summary with the user or organization referenceId.
  3. Call getActivePlan() and isFeatureAvailable() — same helpers as web.
  4. Hide or disable locked UI; open the web dashboard for checkout.
  5. Enforce access on API routes — the extension is not a trust boundary.

What is different in the extension?

ConcernWeb appExtension
Billing checkoutIn-app Stripe / portalLink to web pricing or portal
StateFull React Query cacheThin summary query
EntitlementsDB subscriptionsAPI summary (no local store SDK)
Upgrade UXModal or /pricingtarget="_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:

apps/extension/src/modules/billing/lib/api.ts
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:

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

apps/extension/src/modules/billing/hooks/use-billing-access.ts
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

Disable premium actions and explain why:

apps/extension/src/modules/popup/premium-action.tsx
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} />;

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:

apps/extension/src/modules/user/user-navigation.tsx
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 staleTime on the summary query (e.g. 30–60 seconds).
  • Refetch when the popup opens (refetchOnMount: "always").
  • Listen for chrome.storage events 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 /pricing

Checklist

  • useBillingAccess used in popup, options, and background worker
  • referenceId matches 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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter