One set of guard functions covers feature flags, plan features, and permissions. They compose in beforeLoad, throw typed errors that RouteErrorComponent maps to UX, and read from the same session context that Storybook mocks globally. Nav visibility and route access use the same underlying checks — they can’t drift.

Three access dimensions

Dimension Check Source
Feature flag getFeatureFlag(key) flag service, or a local override
Plan feature hasPlanFeature(plan, name) Plan record from API
permission can(perms, stmt) Permissions record from API

Each guard is a one-liner that throws a typed error on denial:

function requireFeatureFlag(key: string): void {
  if (!getFeatureFlag(key)) throw new FeatureFlagDisabledError(key);
}

function requirePlanFeature(client: QueryClient, name: string): void {
  const plan = client.getQueryData<Plan>(planQueryKey());
  if (!hasPlanFeature(plan, name)) throw new NotInPlanError(name);
}

function requireAll(client: QueryClient, ...stmts: Permission[]): void {
  const perms = client.getQueryData<Perms>(permissionsQueryKey());
  if (!stmts.every(s => can(perms, s))) throw new AccessDeniedError();
}

function requireAny(client: QueryClient, ...stmts: Permission[]): void {
  const perms = client.getQueryData<Perms>(permissionsQueryKey());
  if (!stmts.some(s => can(perms, s))) throw new AccessDeniedError();
}

Convenience wrappers for common CRUD patterns:

const requireCanCreate = (client, area) => requireAny(client, { action: "CREATE", area });
const requireCanRead   = (client, area) => requireAny(client, { action: "READ",   area });
const requireCanUpdate = (client, area) => requireAny(client, { action: "UPDATE", area });
const requireCanDelete = (client, area) => requireAny(client, { action: "DELETE", area });

Route usage

export const Route = createFileRoute("/reports")({
  beforeLoad: ({ context: { queryClient } }) => {
    requireFeatureFlag("reports-v2");     // must have flag
    requirePlanFeature(queryClient, "reports"); // must have plan feature
    requireCanRead(queryClient, "reports");    // must have read permission
  },
});

Sequential calls are AND. The first failure throws; subsequent checks are skipped.

In-component usage

Same primitives, read synchronously from cache, used for conditional rendering:

function ReportsToolbar({ queryClient }: { queryClient: QueryClient }) {
  const perms = queryClient.getQueryData<Perms>(permissionsQueryKey());
  const canCreate = can(perms, { action: "CREATE", area: "reports" });

  return (
    <div>
      {canCreate && <Button>Create report</Button>}
    </div>
  );
}

The same underlying functions gate nav visibility and route access:

// nav config
{
  label: "Reports",
  path:  "/reports" satisfies RoutePath,
  hidden: !getFeatureFlag("reports-v2") || !hasPlanFeature(plan, "reports"),
}

// route beforeLoad
requireFeatureFlag("reports-v2");
requirePlanFeature(queryClient, "reports");

If you hide the nav link behind a flag, the route guards the same flag. Structural consistency — not a convention you have to remember.

Storybook: global auth defaults

Every story runs with full admin access and all flags enabled by default, wired in preview.tsx:

// .storybook/preview.tsx
const mockSession: Session = {
  accountId:    "test-account",
  featureFlags: allFlagsOn(),   // every flag returns true
  plan:         fullPlan(),     // every plan feature included
  permissions:  fullAdmin(),    // every permission allowed
};

const preview: Preview = {
  decorators: [
    (Story) => (
      <SessionProvider value={mockSession}>
        <Story />
      </SessionProvider>
    ),
  ],
};

Stories never hit real auth. The default session lets every component render its “authorized” state. Specific stories override only the dimensions they’re testing.

Storybook: per-story overrides

// Read-only view
export const ReadOnly: Story = {
  decorators: [
    withSession({
      permissions: noWritePermissions("reports"),
    }),
  ],
};

// Behind a flag
export const FeatureFlagOff: Story = {
  decorators: [
    withSession({
      featureFlags: { "reports-v2": false },
    }),
  ],
};

// Non-plan account
export const NotEntitled: Story = {
  decorators: [
    withSession({
      plan: planWithout("reports"),
    }),
  ],
};

withSession is a decorator factory that merges overrides into the mock session context:

function withSession(overrides: Partial<Session>): Decorator {
  return (Story) => (
    <SessionProvider value={mergeSession(mockSession, overrides)}>
      <Story />
    </SessionProvider>
  );
}

The same SessionProvider the real app uses. No special Storybook-only auth system to maintain.

Why this works

All three access dimensions — flags, plan features, permissions — read from a single SessionProvider context. Mocking that context in Storybook mocks all three simultaneously. There’s no separate flag-service mock, no separate plan mock, no separate permissions mock to keep in sync. One context, one override, full control.