Feature flagging for free trial users is the practice of selectively enabling or disabling product features on the frontend based on a user's trial status, plan level, or usage limits. Unlike traditional feature flags used for gradual rollouts or A/B testing, trial-specific flags create a deliberate value gap between the trial experience and the paid product. When implemented correctly, feature gating during trials increases trial-to-paid conversion by 12-22% because users can see what they are missing and understand the concrete value of upgrading.
The challenge is implementation. You need a flag system that is simple enough to maintain, a UI that communicates the blocked state without frustrating users, and a measurement strategy to know which gated features actually drive upgrades. This guide covers all three, with production-ready code for each pattern. It also shows how TrialMoments handles the upgrade prompt UX when you call triggerBlockedFeature(), so you control the flags while TrialMoments handles the conversion moment.
Basic Feature Flag Implementation
The simplest feature flag system for trial gating is a configuration object that maps feature names to access rules. You do not need a third-party flag service to start. A static config, a React context provider, and a FeatureGate component give you everything you need for most trial gating scenarios.
The Feature Flag Config
// lib/feature-flags.ts
export type PlanLevel = 'trial' | 'starter' | 'pro' | 'enterprise';
export interface FeatureFlag {
name: string;
description: string;
enabledForPlans: PlanLevel[];
// Optional: limit usage instead of blocking entirely
usageLimit?: {
trial: number;
starter: number;
pro: number;
enterprise: number;
};
}
export const featureFlags: Record<string, FeatureFlag> = {
'advanced-analytics': {
name: 'Advanced Analytics',
description: 'Custom dashboards, cohort analysis, and export',
enabledForPlans: ['pro', 'enterprise'],
},
'team-collaboration': {
name: 'Team Collaboration',
description: 'Invite team members and share workspaces',
enabledForPlans: ['starter', 'pro', 'enterprise'],
},
'api-access': {
name: 'API Access',
description: 'REST API with full read/write access',
enabledForPlans: ['pro', 'enterprise'],
usageLimit: {
trial: 100, // 100 API calls during trial
starter: 10000,
pro: 100000,
enterprise: Infinity,
},
},
'custom-branding': {
name: 'Custom Branding',
description: 'White-label with your brand colors and logo',
enabledForPlans: ['enterprise'],
},
'export-csv': {
name: 'CSV Export',
description: 'Export data to CSV and Excel formats',
enabledForPlans: ['starter', 'pro', 'enterprise'],
usageLimit: {
trial: 3, // 3 exports during trial
starter: 50,
pro: Infinity,
enterprise: Infinity,
},
},
};This config-driven approach has a major advantage: product and engineering can update gating rules without deploying code. Move the config to a CMS, database, or environment variable and you have a lightweight feature flag system without any third-party dependency.
The Feature Flag Context Provider
// contexts/FeatureFlagContext.tsx
'use client';
import React, { createContext, useContext, useMemo } from 'react';
import { featureFlags, PlanLevel, FeatureFlag } from '@/lib/feature-flags';
interface FeatureFlagContextValue {
userPlan: PlanLevel;
isFeatureEnabled: (featureName: string) => boolean;
getFeatureFlag: (featureName: string) => FeatureFlag | undefined;
getRemainingUsage: (featureName: string, currentUsage: number) => number;
}
const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export function FeatureFlagProvider({
userPlan,
children,
}: {
userPlan: PlanLevel;
children: React.ReactNode;
}) {
const value = useMemo(() => ({
userPlan,
isFeatureEnabled: (featureName: string): boolean => {
const flag = featureFlags[featureName];
if (!flag) return true; // unknown features default to enabled
return flag.enabledForPlans.includes(userPlan);
},
getFeatureFlag: (featureName: string) => featureFlags[featureName],
getRemainingUsage: (featureName: string, currentUsage: number): number => {
const flag = featureFlags[featureName];
if (!flag?.usageLimit) return Infinity;
const limit = flag.usageLimit[userPlan] ?? 0;
return Math.max(0, limit - currentUsage);
},
}), [userPlan]);
return (
<FeatureFlagContext.Provider value={value}>
{children}
</FeatureFlagContext.Provider>
);
}
export function useFeatureFlags() {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within FeatureFlagProvider');
}
return context;
}The FeatureGate Component
This is the component you wrap around any gated feature. It checks the flag, renders the feature if enabled, or renders a blocked-state fallback if not. The fallback is where conversion happens, so its design matters enormously. For more on trial feature gating patterns, see our dedicated guide.
// components/FeatureGate.tsx
'use client';
import React from 'react';
import { useFeatureFlags } from '@/contexts/FeatureFlagContext';
interface FeatureGateProps {
featureName: string;
children: React.ReactNode;
fallback?: React.ReactNode;
onBlocked?: () => void;
}
export function FeatureGate({
featureName,
children,
fallback,
onBlocked,
}: FeatureGateProps) {
const { isFeatureEnabled, getFeatureFlag } = useFeatureFlags();
if (isFeatureEnabled(featureName)) {
return <>{children}</>;
}
const flag = getFeatureFlag(featureName);
// Default fallback: a locked-feature card
const defaultFallback = (
<div className="relative rounded-lg border-2 border-dashed border-gray-300
bg-gray-50 p-8 text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-gray-200
flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2
0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">
{flag?.name ?? 'Premium Feature'}
</h3>
<p className="text-sm text-gray-500 mb-4">
{flag?.description ?? 'This feature is available on a paid plan.'}
</p>
<button
onClick={onBlocked}
className="px-6 py-2 bg-blue-600 text-white rounded-lg
font-semibold hover:bg-blue-700 transition-colors"
>
Upgrade to Unlock
</button>
</div>
);
return <>{fallback ?? defaultFallback}</>;
}Usage in your app:
// In any component
import { FeatureGate } from '@/components/FeatureGate';
function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Basic reports: available to everyone */}
<BasicReports />
{/* Advanced analytics: gated for trial users */}
<FeatureGate
featureName="advanced-analytics"
onBlocked={() => {
// Track that user attempted to access this feature
analytics.track('feature_blocked', { feature: 'advanced-analytics' });
}}
>
<AdvancedAnalytics />
</FeatureGate>
</div>
);
}Advanced Flag Patterns for Trials
Beyond simple on/off gating, three advanced patterns significantly improve trial conversion: percentage rollout, plan-based progressive gating, and usage-based limits. Each pattern creates a different upgrade motivation.
Pattern 1: Percentage Rollout for Trial Features
Instead of giving all trial users the same feature set, randomize which premium features each trial user can access. This lets you A/B test which gated features drive the most upgrades. A user who gets access to "Advanced Analytics" during their trial but not "Custom Branding" gives you data on which feature is the stronger upgrade driver.
// lib/percentage-rollout.ts
export function isInRollout(
userId: string,
featureName: string,
percentage: number
): boolean {
// Deterministic hash so the same user always gets the same result
const hash = simpleHash(userId + featureName);
return (hash % 100) < percentage;
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0; // Convert to 32-bit integer
}
return Math.abs(hash);
}
// Usage: Give 30% of trial users access to advanced analytics
const hasAdvancedAnalytics = isInRollout(user.id, 'advanced-analytics', 30);Pattern 2: Plan-Based Progressive Gating
Show trial users a taste of each plan level. On day 1, they get Starter features. On day 4, unlock Pro features temporarily. On day 7, lock Pro features back down. This creates a "loss aversion" effect that is more powerful than never showing the feature at all. Users who have experienced a feature and then lost it convert at 2x the rate of users who never saw it.
// lib/progressive-gating.ts
interface TrialPhase {
startDay: number;
endDay: number;
enabledPlans: PlanLevel[];
}
const trialPhases: TrialPhase[] = [
{ startDay: 1, endDay: 3, enabledPlans: ['trial', 'starter'] },
{ startDay: 4, endDay: 7, enabledPlans: ['trial', 'starter', 'pro'] },
{ startDay: 8, endDay: 14, enabledPlans: ['trial', 'starter'] },
];
export function getTrialPlanLevel(trialStartDate: Date): PlanLevel[] {
const daysSinceStart = Math.floor(
(Date.now() - trialStartDate.getTime()) / (1000 * 60 * 60 * 24)
);
const phase = trialPhases.find(
p => daysSinceStart >= p.startDay && daysSinceStart <= p.endDay
);
return phase?.enabledPlans ?? ['trial'];
}Pattern 3: Usage-Based Limits
Instead of blocking features entirely, allow limited usage during the trial. Trial users get 3 CSV exports, 100 API calls, or 5 team members. When they hit the limit, show the upgrade prompt. This pattern converts better than hard blocks because users have already experienced the value and want more.
// components/UsageLimitGate.tsx
'use client';
import { useFeatureFlags } from '@/contexts/FeatureFlagContext';
interface UsageLimitGateProps {
featureName: string;
currentUsage: number;
children: React.ReactNode;
onLimitReached: () => void;
}
export function UsageLimitGate({
featureName,
currentUsage,
children,
onLimitReached,
}: UsageLimitGateProps) {
const { getRemainingUsage, getFeatureFlag } = useFeatureFlags();
const remaining = getRemainingUsage(featureName, currentUsage);
if (remaining <= 0) {
const flag = getFeatureFlag(featureName);
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-6">
<p className="font-semibold text-amber-800 mb-2">
You've used all {currentUsage} of your trial {flag?.name} quota
</p>
<p className="text-sm text-amber-700 mb-4">
Upgrade to get unlimited access to {flag?.name?.toLowerCase()}.
</p>
<button onClick={onLimitReached}
className="px-4 py-2 bg-amber-600 text-white rounded-lg
font-medium hover:bg-amber-700">
Upgrade for Unlimited Access
</button>
</div>
);
}
return (
<>
{remaining <= 2 && (
<div className="text-sm text-amber-600 mb-2">
{remaining} use{remaining !== 1 ? 's' : ''} remaining in your trial
</div>
)}
{children}
</>
);
}Integrating with Existing Flag Services
If you already use LaunchDarkly, Flagsmith, or another flag service, you do not need to build a separate system for trial gating. Instead, extend your existing flags with trial-specific targeting rules. The key is to pass user plan and trial status as context attributes so your flag rules can target trial users specifically.
LaunchDarkly Integration
// lib/launchdarkly-trial.ts
import { LDClient } from 'launchdarkly-js-client-sdk';
export function initTrialFlags(ldClient: LDClient, user: {
id: string;
plan: string;
trialDaysRemaining: number;
trialStartDate: string;
}) {
// Set user context with trial attributes
ldClient.identify({
key: user.id,
custom: {
plan: user.plan,
isTrialUser: user.plan === 'trial',
trialDaysRemaining: user.trialDaysRemaining,
trialStartDate: user.trialStartDate,
},
});
}
// In LaunchDarkly dashboard, create targeting rules:
// IF plan = "trial" AND trialDaysRemaining <= 3
// THEN enable "show-urgency-banner" = true
//
// IF plan = "trial"
// THEN enable "advanced-analytics" = false
// ELSE enable "advanced-analytics" = trueFlagsmith Integration
// lib/flagsmith-trial.ts
import flagsmith from 'flagsmith';
export async function initTrialFlags(user: {
id: string;
plan: string;
trialDaysRemaining: number;
}) {
await flagsmith.identify(user.id, {
plan: user.plan,
is_trial: user.plan === 'trial',
trial_days_remaining: user.trialDaysRemaining,
});
}
// Check flags with Flagsmith
export function isFeatureEnabled(featureName: string): boolean {
return flagsmith.hasFeature(featureName);
}
export function getFeatureValue(featureName: string): string | number | boolean {
return flagsmith.getValue(featureName);
}Custom Flag Service Adapter Pattern
If you want to switch between flag providers (or use a custom backend), create an adapter interface. Your FeatureGate component calls the adapter, not the provider directly. This lets you swap LaunchDarkly for Flagsmith (or a custom solution) without touching any component code. This is especially useful if your trial best practices evolve and you need to change your gating strategy.
Designing the "Blocked" State UX
The blocked-state UX is the single most important design decision in trial feature flagging. It is the moment where a user discovers a feature they cannot access and decides whether to upgrade or leave. Most products get this wrong by either hiding gated features (users never see the value) or showing a generic "upgrade" message (no context, no motivation). The best paywall design patterns follow three principles.
Show, Don't Hide
Display a preview, blurred screenshot, or skeleton of the gated feature. Users who can see what they are missing are 3x more motivated to upgrade than those who never encounter the feature.
Communicate Value
Explain what the feature does and why it matters. Not "Upgrade to Pro" but "Custom dashboards help teams make data-driven decisions 40% faster. Available on Pro." Feature-specific value props convert 2x better than generic upgrade CTAs.
Single CTA
One button. One action. "Upgrade to Unlock" or "Start Pro Trial." Do not offer multiple plan options in the blocked state—that creates decision paralysis. Link directly to the relevant plan's checkout.
Here is a more conversion-focused FeatureGate fallback that applies these principles:
// components/PremiumFeatureBlock.tsx
interface PremiumFeatureBlockProps {
featureName: string;
title: string;
description: string;
previewImageUrl?: string;
upgradeUrl: string;
onUpgradeClick?: () => void;
}
export function PremiumFeatureBlock({
featureName,
title,
description,
previewImageUrl,
upgradeUrl,
onUpgradeClick,
}: PremiumFeatureBlockProps) {
return (
<div className="relative rounded-xl border-2 border-dashed border-gray-200
overflow-hidden">
{/* Blurred preview background */}
{previewImageUrl && (
<div className="absolute inset-0 blur-sm opacity-30">
<img src={previewImageUrl} alt="" className="w-full h-full
object-cover" />
</div>
)}
{/* Overlay content */}
<div className="relative bg-white/80 backdrop-blur-sm p-8 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full
bg-primary/10 text-primary text-sm font-medium mb-4">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2
0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Pro Feature
</div>
<h3 className="text-xl font-bold mb-2">{title}</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">{description}</p>
<a
href={upgradeUrl}
onClick={onUpgradeClick}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary
text-white rounded-lg font-semibold hover:bg-primary/90
transition-colors"
>
Upgrade to Unlock
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</div>
);
}Measuring Feature Flag Impact on Conversion
Feature flags without measurement are guesswork. You need to track three events for each gated feature to understand its impact on trial conversion rates.
Event 1: Feature Encounter
Track when a trial user reaches a gated feature for the first time. This tells you which premium features trial users actually try to access. If nobody encounters "Custom Branding" during the trial, gating it has zero conversion impact.
analytics.track('feature_encountered', {
feature: featureName,
userPlan: 'trial',
trialDay: getDaysSinceTrialStart(),
});Event 2: Upgrade Intent
Track when a user clicks the upgrade CTA on a blocked feature. Compare upgrade intent rates across features to identify which gates drive the most motivation.
analytics.track('upgrade_intent', {
feature: featureName,
source: 'feature_gate',
trialDay: getDaysSinceTrialStart(),
});Event 3: Conversion Attribution
When a user converts, log which gated features they encountered during the trial. This lets you calculate the conversion rate for users who hit each gate vs. those who did not. The feature with the highest "encounter to conversion" ratio is your strongest upgrade driver.
TrialMoments: You Handle Flags, We Handle the Upgrade Prompt
TrialMoments does not replace your feature flagging logic. It complements it. You decide which features are gated and implement the flag checks using any of the patterns above. When a user hits a gate, you call triggerBlockedFeature() and TrialMoments displays a conversion-optimized modal with the feature value prop, remaining trial context, and a clear upgrade path.
// Initialize TrialMoments once
TrialMoments.init({
accountId: 'your-id',
trialEndDate: '2026-04-15',
upgradeUrl: '/upgrade',
});
// In your FeatureGate onBlocked callback:
<FeatureGate
featureName="advanced-analytics"
onBlocked={() => {
// TrialMoments shows a conversion-optimized upgrade prompt
TrialMoments.triggerBlockedFeature('advanced-analytics');
}}
>
<AdvancedAnalytics />
</FeatureGate>Why Separate Flags from Prompts?
Turn Feature Gates into Conversion Moments
TrialMoments provides conversion-optimized upgrade prompts for blocked features, trial countdown timers, and expiration banners. You handle the flags; TrialMoments handles the conversion UX. 30KB bundle, 5-minute setup.
FAQ: Feature Flagging for Trial Users
What is feature flagging for trial users?
Feature flagging for trial users is the practice of selectively enabling or disabling product features based on a user's trial status, plan level, or usage limits. Unlike traditional feature flags used for gradual rollouts, trial-specific flags control which features are accessible during a free trial to create upgrade incentives. For example, a trial user might have access to basic reporting but see a locked state on advanced analytics, prompting them to upgrade. This technique directly impacts conversion rates by letting users see the value of premium features without full access.
How do I implement a FeatureGate component in React?
A FeatureGate component in React wraps any feature content and conditionally renders it based on flag state. Create a component that accepts a featureName prop, checks the flag value from a context provider, and either renders its children (if the flag is enabled) or renders a fallback component (if blocked). The fallback should communicate what the feature does and provide a clear upgrade path. Use React Context to provide flag values from your API or flag service, so FeatureGate components throughout your app can access them without prop drilling.
Should I use LaunchDarkly or build custom feature flags for trial gating?
It depends on your existing infrastructure and scale. If you already use LaunchDarkly, Flagsmith, or a similar service for feature rollouts, extend it for trial gating by adding user plan and trial status as targeting attributes. This leverages your existing setup and avoids duplicate systems. If you do not use a flag service yet, a simple custom implementation with a config object and React context is sufficient for trial gating and avoids adding a dependency. For most SaaS products under 10,000 users, a custom solution with 50-100 lines of code handles trial feature flagging effectively.
What should I show when a trial user hits a feature gate?
The blocked-state UX is critical for conversion. Show three things: (1) a clear indication that the feature exists and what it does, using a preview, screenshot, or blurred version of the real feature, (2) a brief explanation of why it is locked, such as "Available on Pro plan" or "Upgrade to unlock advanced analytics", and (3) a prominent, single-action CTA to upgrade. Avoid showing an empty state or a generic "upgrade" message. The goal is to let the user feel the value gap between their current trial and the paid plan. Products that show contextual blocked states convert 12-22% better than those that simply hide gated features.
How does TrialMoments handle feature gating?
TrialMoments does not replace your feature flagging logic. Instead, it handles the upgrade prompt UX when a user hits a gated feature. You implement the flag logic (checking plan, usage limits, or flag service), and when a feature is blocked, you call TrialMoments.triggerBlockedFeature('feature-name'). TrialMoments then displays a conversion-optimized modal that shows the feature value, remaining trial context, and a clear upgrade path. This separation means you keep full control over which features are gated while TrialMoments handles the conversion moment with pre-tested, high-converting designs.
Related Articles
Ready to Convert More Trial Users at Feature Gates?
TrialMoments turns every feature gate into a conversion opportunity with pre-tested upgrade prompts. Deploy in 5 minutes alongside your existing feature flags.
Get Started with TrialMoments