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.
The 3 Layers
Each layer serves a distinct purpose. Together they guarantee no free access to app features under any navigation scenario.
| Layer | File | Purpose |
|---|---|---|
| 1. Entry Router | src/app/index.tsx | Initial routing on cold start. Waits for premium status to load, then routes to onboarding, paywall, or app. |
| 2. Layout Guard | src/app/(app)/_layout.tsx | Safety net for deep links, back navigation, and edge cases. Bounces non-subscribers to /premium. |
| 3. Status Hook | src/hooks/usePremiumStatus.ts | Single 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.
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
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.
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:
- User taps Subscribe on
premium.tsx purchasePackage()calls RevenueCat- On success,
refreshPremiumStatus()re-checks entitlements usePremiumStore.isSubscribedbecomestrue- Layer 2's useEffect fires — user is on premium screen, so no redirect
- After confetti animation (2.5s),
handleClose()navigates to/(app)/(tabs)
RevenueCat Configuration
config.ts setup
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" exactly. A mismatch means users pay but the app still shows the paywall.RevenueCat Offerings setup
In the RevenueCat dashboard:
- Create entitlement:
premium - Attach both products (weekly + yearly) to the entitlement
- Create
defaultoffering - Add Weekly package (attach weekly product)
- 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.
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 field | Required value | Why |
|---|---|---|
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.periodNumberOfUnits | 7 | Number 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();__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.
{
"build": {
"production": {
"env": {
"APP_ENV": "production",
"EXPO_PUBLIC_REVENUECAT_IOS_KEY": "appl_YourActualKey",
"EXPO_PUBLIC_REVENUECAT_ANDROID_KEY": "goog_YourActualKey"
}
}
}
}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.3-layer hard paywall ready to ship
Proven across 5 App Store apps. RevenueCat integration, mock offerings, and dev skip button included.