BUILT INTO EVERY GOODSPEED APP
Soft Paywall (Usage Gates)
A usePaywall hook wraps any feature with a usage limit check that transparently redirects non-paying users to the paywall modal without requiring the feature code to know anything about subscription state.
- Tier: Common
- Status: Config-toggled
- Config: features.inAppPurchases.enabled
WHY IT MATTERS
Every consumer app that monetizes with subscriptions faces the same wiring problem: how do you let a feature know whether the current user has paid without coupling every screen to subscription logic? The naive answer is to import subscription state at the feature level, check it inline, and scatter redirect logic across the codebase. That works until you add a new tier, rename an entitlement, or need to change the paywall destination. Then every feature file needs updating. The template takes a different approach. The usePaywall hook owns the entire gate: it fetches the user record, compares the current tier against the required tier, and handles both the unauthenticated case (redirect to login) and the under-tier case (redirect to paywall modal) without the feature code knowing either route exists. A feature is gated with a single awaited call that returns a boolean. If it returns false, the feature returns early. Nothing else is needed.
The tier comparison is not hardcoded to specific strings. TIER_NAMES is derived at module load time from gasConfig.features.inAppPurchases.tiers, the same array that drives the paywall screen copy and RevenueCat product identifiers. Adding an 'enterprise' tier above 'pro' requires changing one line in gas.config.ts. All existing checkAccess calls that require 'pro' continue to work correctly because they compare numeric tier indexes rather than string equality. The second primitive, checkUsageLimit, adds metered access: call it with the current usage count and a free-tier cap, and it returns true if the user is within the limit or on a sufficient tier, routing to the paywall only when both conditions fail. This enables the classic freemium unlock pattern where users get 5 free generations, then hit the gate, with no quota logic in the feature screen.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from hooks/usePaywall.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// hooks/usePaywall.ts (gas-template) -- config-driven tier gate
// TIER_NAMES is derived from gasConfig so tier names are never hardcoded.
const TIER_NAMES = gasConfig.features.inAppPurchases.tiers.map(t => t.name.toLowerCase());
function tierLevel(tierName: string | null): number {
if (!tierName) return 0;
const idx = TIER_NAMES.indexOf(tierName.toLowerCase());
return idx >= 0 ? idx : 0;
}
export function usePaywall() {
const checkAccess = useCallback(async (
requiredTier: string,
redirectOnFail = true,
): Promise<boolean> => {
const user = await getUser();
if (!user) {
if (redirectOnFail) router.push('/(auth)/login');
return false;
}
const currentTierName = await getUserTier(user.id);
const currentLevel = tierLevel(currentTierName);
const requiredLevel = tierLevel(requiredTier);
if (currentLevel >= requiredLevel) return true;
if (redirectOnFail) router.push('/(modal)/paywall');
return false;
}, [getUser, getUserTier]);
const checkUsageLimit = useCallback(async (
currentUsage: number,
freeLimit: number,
requiredTier?: string,
): Promise<boolean> => {
if (currentUsage < freeLimit) return true;
return checkAccess(requiredTier ?? (TIER_NAMES[1] ?? 'pro'));
}, [checkAccess]);
return { checkAccess, checkUsageLimit, checkProductOwnership, checkCreditAccess };
}Source: goodspeed-apps/gas-template → hooks/usePaywall.ts
HONEST LIMITS
When Soft Paywall (Usage Gates) is the wrong choice
The soft gate is designed for apps where the free tier delivers real, working value and conversion happens by demonstrating that value before asking for payment. If your app has no viable free-tier experience, if any meaningful action requires a subscription before the user has seen what the product does, then a soft gate is the wrong tool. A full-screen hard paywall shown at launch converts higher for apps where the value is explicit (a tool the user searched for by name), and the template's Paywall screen component supports both patterns. Use a hard paywall when the free tier would be a deliberately crippled experience that frustrates rather than converts. Also avoid this pattern when the subscription state is authoritative on the server and cannot safely be derived from a local tier string. If your backend enforces feature access via JWT claims or a dedicated entitlement API, the profiles.subscription_tier column adds a second source of truth that can drift out of sync after a billing event. In those architectures, call a server-side entitlement check instead of relying on the local DB column. The hook is designed for the common case where RevenueCat and Supabase are the single source of truth and the subscription webhook writes back to profiles.subscription_tier synchronously.
Tier: Common · Config-toggled
Evaluate your use case
Check whether soft paywall (usage gates) aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.inAppPurchases.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes soft paywall (usage gates). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Soft Paywall (Usage Gates) capability breakdown
Concrete dimensions of what the built-in soft paywall (usage gates) implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Gate mechanism | checkAccess() reads subscription_tier from the Supabase profiles table and compares it to the required tier via numeric indexes derived from gasConfig.features.inAppPurchases.tiers. Higher index equals higher tier. | Tier-level comparison |
| Usage metering | checkUsageLimit(currentUsage, freeLimit, requiredTier) lets any integer counter serve as the per-tier cap. If currentUsage is below freeLimit the call returns true with no network round-trip. | Count-based |
| Redirect target | Unauthenticated users are routed to /(auth)/login. Authenticated under-tier users are routed to /(modal)/paywall. Neither destination is imported into the feature code that calls the hook. | Automatic routing |
| Feature coupling | Feature screens call await checkAccess('pro') and early-return on false. No subscription state, tier names, or paywall routes are imported into feature-level components. | Zero coupling |
| Config surface | Tier names and ordering are read from gasConfig.features.inAppPurchases.tiers at module load. Adding a new tier requires only a config change, not editing any feature file. | Config-driven |
COMMON QUESTIONS
Does the gate make a network request on every feature open?
checkAccess() calls supabase.auth.getUser() and then queries the profiles table once per invocation. There is no in-memory subscription cache in the hook itself; that is intentional to keep the hook stateless. If your app opens gated features frequently and you want to reduce Supabase reads, wrap the hook call with a short-lived local state (for example, store the tier result for a minute using useRef and a timestamp check). The useSubscription hook, used by the paywall modal itself, does maintain state and provides the isPaid and tier values you can pass down via context if you prefer a single refresh-on-mount pattern.
What happens if profiles.subscription_tier is null because the row was just created?
If getUserTier returns null, checkAccess treats the user as tier-level zero (the free tier). This is intentional: a missing or null tier is the safest default because it never grants access that has not been purchased. The webhook that writes back the subscription_tier after a RevenueCat purchase event is part of the revenuecat-subscriptions feature. If you are testing locally without the webhook running, set subscription_tier manually in the Supabase dashboard, or use the Supabase service role client in a seed script to set it to your dev tier name before testing the gate.
Can I use checkUsageLimit with a value stored in Supabase rather than a local count?
Yes. checkUsageLimit accepts any integer as the first argument; it does not fetch from Supabase itself. You fetch the count from wherever it lives, pass it in, and the hook compares it against freeLimit. A common pattern is to fetch usage from a usage_counts table in the same query that loads the screen data, then call checkUsageLimit(data.generationCount, 5, 'pro') after the query resolves. This keeps the network round-trip count at one and makes the gate synchronous from the perspective of the feature screen.
Does this work on web (Expo web target)?
Yes. The hook uses supabase.auth.getUser() and a Supabase table query, both of which work on web without modification. expo-router's router.push() also works on web. The only web-specific consideration is that the paywall modal route (/(modal)/paywall) must be defined in your Expo Router file tree. If you have disabled the modal group or renamed it, update the redirect path passed to router.push() inside checkAccess. The hook does not depend on any native-only APIs.
GET IT BUILT INTO YOUR APP