"I built a free plan, but nobody converts to paid" — I've heard this from solo developers more times than I can count. I've lived through it myself.
Most conversion problems trace back to one root cause: bad freemium design. If you've built a product where free users are perfectly satisfied and you're just hoping they'll upgrade someday, that's not a strategy — it's wishful thinking.
Product-Led Growth (PLG) and its practical extension, the reverse trial, have reshaped how the best SaaS and consumer apps think about monetization. Applying these ideas to Rork apps changed my conversion numbers dramatically. Here's how.
PLG vs. Traditional Freemium — What's Actually Different
PLG means the product itself drives acquisition, activation, and revenue — without relying heavily on advertising or sales. The key distinction from traditional freemium is the philosophy behind feature restrictions.
Traditional freemium restricts first: free users get a crippled version, and the message is "pay to get more." Users encounter walls constantly and build up frustration and resistance.
PLG gives value first: users experience the full product, develop genuine attachment, then choose to pay to maintain that value. The message is "you've already seen what this is worth — do you want to keep it?"
That shift in framing is enormous. Restriction-based freemium gives users reasons not to pay. PLG-based freemium gives users reasons to pay before asking.
Designing the Reverse Trial
The most effective PLG implementation for app developers is the reverse trial. Here's how it differs from standard trials:
Standard trial: free plan → limited-time access to premium features → reverts to free tier Reverse trial: full premium experience from day one → after N days, downgrade to free tier → user converts to keep what they've built
The psychology is completely different. A standard trial is about future potential ("maybe I'll find this valuable"). A reverse trial activates loss aversion ("I have something valuable now, and I'll lose it"). Behavioral economics research consistently shows that people respond more strongly to losing something than to gaining something of equal value.
Here's a Supabase-backed implementation for Rork apps:
// hooks/useUserTier.ts
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
interface UserTier {
tier: 'reverse_trial' | 'free' | 'pro';
trialEndsAt: Date | null;
isTrialActive: boolean;
daysRemaining: number;
}
export function useUserTier(): UserTier {
const [tierData, setTierData] = useState<UserTier>({
tier: 'reverse_trial',
trialEndsAt: null,
isTrialActive: true,
daysRemaining: 14,
});
useEffect(() => {
const fetchTier = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase
.from('user_subscriptions')
.select('tier, trial_ends_at')
.eq('user_id', user.id)
.single();
if (data) {
const trialEndsAt = data.trial_ends_at
? new Date(data.trial_ends_at)
: null;
const now = new Date();
const isTrialActive = trialEndsAt ? trialEndsAt > now : false;
const daysRemaining = trialEndsAt
? Math.max(0, Math.ceil(
(trialEndsAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
))
: 0;
setTierData({
tier: data.tier as UserTier['tier'],
trialEndsAt,
isTrialActive,
daysRemaining,
});
}
};
fetchTier();
}, []);
return tierData;
}Set trial_ends_at automatically on signup with a Supabase database trigger:
CREATE OR REPLACE FUNCTION initialize_trial()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_subscriptions (user_id, tier, trial_ends_at)
VALUES (NEW.id, 'reverse_trial', NOW() + INTERVAL '14 days')
ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION initialize_trial();Defining and Detecting Your Aha Moment
For the reverse trial to work, you need to know whether users are actually experiencing value during the trial period. This requires defining your app's Aha Moment — the specific action or outcome where users first feel "this is genuinely useful to me."
Famous examples: Facebook's "7 friends in 10 days," Slack's "2,000 team messages sent." Your app has its own equivalent.
For a journaling app it might be "3 consecutive days of entries." For a habit tracker it might be "first 7-day streak completed." You start with a hypothesis, then validate it with data — users who hit this milestone should have significantly higher 30-day retention than those who don't.
// lib/activationTracker.ts
import analytics from '@react-native-firebase/analytics';
class ActivationTracker {
private static instance: ActivationTracker;
private achievedMilestones: Set<string> = new Set();
static getInstance(): ActivationTracker {
if (!this.instance) {
this.instance = new ActivationTracker();
}
return this.instance;
}
async trackMilestone(userId: string, milestone: string): Promise<void> {
const key = `${userId}:${milestone}`;
if (this.achievedMilestones.has(key)) return; // Prevent duplicates
this.achievedMilestones.add(key);
await analytics().logEvent('activation_milestone', {
milestone,
user_id: userId,
timestamp: Date.now(),
});
// Persist to Supabase
await supabase.from('activation_milestones').upsert({
user_id: userId,
milestone,
achieved_at: new Date().toISOString(),
});
// Check if Aha Moment criteria are met
await this.checkAhaMoment(userId);
}
private async checkAhaMoment(userId: string): Promise<void> {
const { data: milestones } = await supabase
.from('activation_milestones')
.select('milestone')
.eq('user_id', userId);
const achieved = new Set(milestones?.map(m => m.milestone) ?? []);
// Define your own criteria here
const AHA_CRITERIA = [
'first_core_action',
'repeated_use_3x',
'data_created_5_items',
];
if (AHA_CRITERIA.every(c => achieved.has(c))) {
await analytics().logEvent('aha_moment_reached', { user_id: userId });
await supabase.from('user_subscriptions')
.update({ aha_moment_at: new Date().toISOString() })
.eq('user_id', userId);
}
}
}
export const activationTracker = ActivationTracker.getInstance();Show the Paywall After Value — Not Before
The single most important design rule in PLG: trigger the paywall immediately after the user has experienced value, not before.
Showing a paywall at app launch is the worst possible timing. The user has no context for what they're being asked to pay for. The emotional contract hasn't been established. But showing the paywall right after the user completes their first successful use of the core feature — when they're feeling the most positive about the product — is when conversion rates are highest.
// components/SmartPaywall.tsx
import React, { useEffect, useState } from 'react';
import { useUserTier } from '@/hooks/useUserTier';
interface SmartPaywallProps {
trigger: 'value_delivered' | 'feature_limit' | 'trial_ending';
onDismiss?: () => void;
}
export function SmartPaywall({ trigger, onDismiss }: SmartPaywallProps) {
const { tier, daysRemaining, isTrialActive } = useUserTier();
const [shouldShow, setShouldShow] = useState(false);
useEffect(() => {
if (tier === 'pro') return;
const checkShouldShow = async () => {
switch (trigger) {
case 'value_delivered': {
// Only show after Aha Moment, and only once
const { data } = await supabase
.from('user_subscriptions')
.select('aha_moment_at, paywall_shown_after_aha')
.eq('user_id', currentUserId)
.single();
if (data?.aha_moment_at && !data?.paywall_shown_after_aha) {
setShouldShow(true);
await supabase.from('user_subscriptions')
.update({ paywall_shown_after_aha: true })
.eq('user_id', currentUserId);
}
break;
}
case 'trial_ending':
if (isTrialActive && daysRemaining <= 3) setShouldShow(true);
break;
case 'feature_limit':
setShouldShow(!isTrialActive && tier === 'free');
break;
}
};
checkShouldShow();
}, [trigger, tier, daysRemaining, isTrialActive]);
if (!shouldShow) return null;
return <PaywallModal trigger={trigger} daysRemaining={daysRemaining} onDismiss={() => { setShouldShow(false); onDismiss?.(); }} />;
}Designing Trial Expiry Notifications That Don't Feel Pushy
Three-stage notification sequence that I've found works well:
// lib/trialNotifications.ts
import * as Notifications from 'expo-notifications';
import { addDays, differenceInDays } from 'date-fns';
export async function scheduleTrialNotifications(trialEndsAt: Date): Promise<void> {
const daysLeft = differenceInDays(trialEndsAt, new Date());
// Day 7 before end: gratitude framing
if (daysLeft >= 7) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Thanks for using the app',
body: "You've been on the full plan for a week. Hope it's been useful.",
data: { type: 'trial_reminder', daysLeft: 7 },
},
trigger: { date: addDays(new Date(), daysLeft - 7) },
});
}
// Day 3 before end: gentle prompt
if (daysLeft >= 3) {
await Notifications.scheduleNotificationAsync({
content: {
title: '3 days left on your full plan',
body: 'Check your plan options before your trial wraps up.',
data: { type: 'trial_reminder', daysLeft: 3 },
},
trigger: { date: addDays(new Date(), daysLeft - 3) },
});
}
// Day before: choice framing (not urgency)
await Notifications.scheduleNotificationAsync({
content: {
title: 'Your full plan ends tomorrow',
body: 'Keep going or switch to the free plan — your call.',
data: { type: 'trial_ending', daysLeft: 1 },
},
trigger: { date: addDays(trialEndsAt, -1) },
});
}The first notification starts with appreciation, not a pitch. The last one presents a choice rather than creating urgency. "Your call" is one of the most effective phrases in SaaS copywriting — it respects the user's autonomy and paradoxically makes them more likely to convert.
Designing the Downgrade Experience
When the trial ends and users move to the free tier, how you handle the transition determines whether they churn or eventually convert. The cardinal rule: never delete user data.
// hooks/useFeatureAccess.ts
export function useFeatureAccess(feature: string): {
canUse: boolean;
reason: 'active' | 'trial_active' | 'free_limit' | 'upgrade_needed';
itemsRemaining?: number;
} {
const { tier, isTrialActive } = useUserTier();
const FREE_LIMITS: Record<string, number> = {
'create_item': 10, // Free users can create up to 10 items
'export_pdf': 0, // Free users cannot export
'ai_suggestions': 3, // Free users get 3 AI suggestions per day
};
if (tier === 'pro') return { canUse: true, reason: 'active' };
if (isTrialActive) return { canUse: true, reason: 'trial_active' };
const limit = FREE_LIMITS[feature];
if (limit === undefined) return { canUse: true, reason: 'active' };
if (limit === 0) return { canUse: false, reason: 'upgrade_needed' };
const currentUsage = 0; // Fetch from Supabase
const remaining = Math.max(0, limit - currentUsage);
return {
canUse: remaining > 0,
reason: remaining > 0 ? 'free_limit' : 'upgrade_needed',
itemsRemaining: remaining,
};
}After downgrading: all past data remains readable, new creation is limited. This model makes the value proposition of upgrading concrete and immediate — "pay and the limit disappears." Users understand exactly what they're getting.
Measuring What Matters
// analytics/plgMetrics.ts
export async function fetchPLGMetrics(period: '7d' | '30d' | '90d') {
const { data } = await supabase.rpc('get_plg_metrics', { period });
return {
activationRate: data.aha_moment_users / data.total_signups,
trialConversionRate: data.paid_conversions / data.trial_completions,
timeToValueMedianHours: data.median_time_to_aha_hours,
};
}Track these three numbers weekly: activation rate (what % reach Aha Moment), trial conversion rate (what % of trial completions convert), and time to value (median hours from signup to Aha Moment). Optimize in that order — getting users to the Aha Moment faster is almost always the highest-leverage improvement.
When I switched one of my apps from traditional freemium to a 14-day reverse trial, conversion went from 3.2% to 8.7% — roughly 2.7x. The highest-converting trigger was the value_delivered paywall, which converted at over 40%.
If your conversion rate is low, the problem probably isn't your product. It's the timing of when you ask.
Next Steps
Start with a single change: implement the Aha Moment tracker, define your criteria, and log when users reach it. Look at your data after two weeks. Then add the value_delivered paywall trigger. Measure again.
Don't roll out all of this at once — you won't know which change made the difference. One change at a time, with measurement, is how PLG actually works.