Hard Paywall Architecture

The boilerplate uses a 3-layer hard paywall as its default monetization pattern, battle-tested across 5 production App Store apps (Fishify, ReWordly, DebatePro, VoxDub, PurrSense). Users must subscribe before accessing any app features.

This page covers the paywall architecture in depth. For a summary, see the Architecture overview. For subscription product setup, see Store Setup.

The 3 Layers

Each layer serves a distinct purpose. Together they guarantee no free access to app features under any navigation scenario.

LayerFilePurpose
1. Entry Routersrc/app/index.tsxInitial routing on cold start. Waits for premium status to load, then routes to onboarding, paywall, or app.
2. Layout Guardsrc/app/(app)/_layout.tsxSafety net for deep links, back navigation, and edge cases. Bounces non-subscribers to /premium.
3. Status Hooksrc/hooks/usePremiumStatus.tsSingle source of truth. Checks RevenueCat on mount + every 5 minutes. Syncs to shared Zustand store.

Layer 1: Entry Router (index.tsx)

The entry point reads premium status from usePremiumStore and routes accordingly. It waits for usePremiumStatus to finish loading before making any routing decision, preventing a flash of the wrong screen.

src/app/index.tsx
import { usePremiumStatus } from "@hooks/usePremiumStatus";
import { usePremiumStore } from "@store/usePremiumStore";
import { useOnboardingStore } from "@store/useOnboardingStore";

export default function IndexPage() {
  const router = useRouter();
  const { isOnboardingCompleted } = useOnboardingStore();
  const { isLoading: isPremiumLoading } = usePremiumStatus();
  const isPremium = usePremiumStore((s) => s.isSubscribed);

  useEffect(() => {
    if (isPremiumLoading) return;

    if (!isOnboardingCompleted) {
      router.replace("/onboarding");
    } else if (!isPremium) {
      // Hard paywall — must subscribe to access the app
      router.replace("/(app)/premium");
    } else {
      router.replace("/(app)/(tabs)");
    }
  }, [isOnboardingCompleted, isPremium, isPremiumLoading]);

  return <LoadingSpinner />;
}

Layer 2: Layout Guard ((app)/_layout.tsx)

The app layout runs a useEffect that checks subscription status on every navigation change. This catches edge cases that Layer 1 cannot:

  • User navigates via deep link directly to an app route
  • User presses back button from premium screen
  • Subscription expires while the app is open
src/app/(app)/_layout.tsx
import { usePremiumStatus } from "@hooks/usePremiumStatus";
import { usePremiumStore } from "@store/usePremiumStore";

export default function AppLayout() {
  const { isLoading: isStatusLoading } = usePremiumStatus();
  const isSubscribed = usePremiumStore((s) => s.isSubscribed);
  const router = useRouter();
  const segments = useSegments();

  useEffect(() => {
    if (isStatusLoading) return;
    const onPremium = segments.some((s) => s === "premium");
    if (!isSubscribed && !onPremium) {
      router.replace("/(app)/premium");
    }
  }, [isSubscribed, isStatusLoading, segments, router]);

  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="premium" options={{ headerShown: false, presentation: "modal" }} />
      {/* ... other screens */}
    </Stack>
  );
}

Layer 3: usePremiumStatus Hook

This hook is the single source of truth for subscription state. It checks RevenueCat on mount, syncs the result to a shared Zustand store, and re-checks every 5 minutes.

src/hooks/usePremiumStatus.ts
export function usePremiumStatus() {
  const checkPremiumStatus = useCallback(async () => {
    const isPremium = await purchasesService.checkSubscriptionStatus();

    // Sync the shared premium store so the hard-paywall guard sees the change
    usePremiumStore.setState({ isSubscribed: isPremium });
  }, []);

  useEffect(() => {
    checkPremiumStatus();
    // Re-check every 5 minutes
    const interval = setInterval(checkPremiumStatus, 5 * 60 * 1000);
    return () => clearInterval(interval);
  }, [checkPremiumStatus]);

  return { isPremium, isLoading, refresh: checkPremiumStatus };
}

Purchase Flow

When a user subscribes on the premium screen, the flow is:

  1. User taps Subscribe on premium.tsx
  2. purchasePackage() calls RevenueCat
  3. On success, refreshPremiumStatus() re-checks entitlements
  4. usePremiumStore.isSubscribed becomes true
  5. Layer 2's useEffect fires — user is on premium screen, so no redirect
  6. After confetti animation (2.5s), handleClose() navigates to /(app)/(tabs)

RevenueCat Configuration

config.ts setup

src/constants/config.ts
export const APP_CONFIG = {
  // Must match the entitlement ID in RevenueCat dashboard
  PREMIUM_ENTITLEMENT_ID: "premium",

  PRODUCT_IDS: {
    WEEKLY: "yourapp.ios.weekly",
    YEARLY: "yourapp.ios.yearly",
  },
};
PREMIUM_ENTITLEMENT_ID must match RevenueCat: If your RevenueCat entitlement is named "premium", the config must say "premium" exactly. A mismatch means users pay but the app still shows the paywall.

RevenueCat Offerings setup

In the RevenueCat dashboard:

  1. Create entitlement: premium
  2. Attach both products (weekly + yearly) to the entitlement
  3. Create default offering
  4. Add Weekly package (attach weekly product)
  5. Add Annual package (attach yearly product)

Mock Offerings (Local Testing)

RevenueCat does not work in Expo Go or simulators. The boilerplate includes mock offerings for local testing. These must exactly match your real product configuration.

Common bug: Mock offerings using packageType: "MONTHLY" when the real product is WEEKLY. This causes the paywall to show wrong prices or crash. Always use the correct package types.
Mock fieldRequired valueWhy
packageType (yearly)"ANNUAL"RevenueCat uses ANNUAL, not YEARLY
packageType (weekly)"WEEKLY"Must be WEEKLY, not MONTHLY — even if you tested with monthly first
introPrice.periodUnit"DAY"Trial period for yearly plan (e.g., 7-day free trial)
introPrice.periodNumberOfUnits7Number of days for the free trial

Trial day calculation

The trial badge must handle different period units from RevenueCat. Do not assume the period is always in days.

function getTrialDays(introPrice: IntroPrice): number {
  const units = introPrice.periodNumberOfUnits;
  switch (introPrice.periodUnit) {
    case "DAY": return units;
    case "WEEK": return units * 7;
    case "MONTH": return units * 30;
    case "YEAR": return units * 365;
    default: return units;
  }
}

Dev Skip Button

In __DEV__ mode, the premium screen shows a "Skip (Dev)" button to bypass the paywall during development. This button must set the correct store state.

// ✅ Correct — bypasses the paywall guard
usePremiumStore.setState({ isSubscribed: true });

// ❌ Wrong — only marks onboarding as seen, paywall guard still blocks
markPremiumScreenSeen();
The dev skip button only appears when __DEV__ is true (development builds). It is automatically hidden in production builds.

RevenueCat API Keys in EAS

EXPO_PUBLIC_ vars are baked into the JS bundle by Metro at build time. They cannot use EAS Secrets because secrets are not available to Metro.

eas.json
{
  "build": {
    "production": {
      "env": {
        "APP_ENV": "production",
        "EXPO_PUBLIC_REVENUECAT_IOS_KEY": "appl_YourActualKey",
        "EXPO_PUBLIC_REVENUECAT_ANDROID_KEY": "goog_YourActualKey"
      }
    }
  }
}
Do NOT use eas secret:create for EXPO_PUBLIC_* variables. They will be undefined in the JS bundle because Metro cannot read EAS Secrets. Set them as plaintext in eas.json or .env.
Battle-Tested Paywall

3-layer hard paywall ready to ship

Proven across 5 App Store apps. RevenueCat integration, mock offerings, and dev skip button included.

3-layer paywall guard
RevenueCat configured
Mock offerings included
Get ShipReactNative
Save 40+ hours of setup