Back to Blog
UX
9 min read
January 5, 2024

App Onboarding with Deferred Deep Linking: Complete User Journey

Design perfect onboarding experiences that work even when users install your app after clicking a marketing link. Learn how to create seamless user journeys from click to first value with deferred deep linking.

Overview & Benefits

What is Deferred Deep Linking for Onboarding?

Deferred deep linking allows users to click a marketing link, install your app, and then land exactly where they intended - even if they didn't have the app installed when they first clicked. This creates seamless onboarding experiences that maintain context and drive higher engagement rates.

Key Benefits

  • • Seamless user experience
  • • Higher conversion rates
  • • Context preservation
  • • Reduced friction
  • • Better user retention
  • • Improved engagement

Use Cases

  • • Marketing campaign onboarding
  • • Referral program flows
  • • Feature-specific onboarding
  • • Content deep linking
  • • Social media campaigns
  • • Email marketing flows

User Journey Design

Complete User Journey

1

User Clicks Marketing Link

User clicks a link from email, social media, or advertisement

2

App Store Redirect

User is redirected to app store to install the app

3

App Installation

User installs and opens the app for the first time

4

Deferred Deep Link Activation

App detects the original intent and navigates to the right screen

5

Contextual Onboarding

User receives personalized onboarding based on their original intent

Implementation

1. Onboarding Service Setup

Create a service to handle deferred deep linking and onboarding:

dart
class OnboardingService {
  static final FlutterRedirectly _redirectly = FlutterRedirectly();
  static bool _hasProcessedInitialLink = false;
  
  static Future<void> initialize() async {
    await _redirectly.initialize(RedirectlyConfig(
      apiKey: 'YOUR_API_KEY',
      baseUrl: 'https://redirectly.app',
      enableDebugLogging: true,
    ));
    
    // Listen for app install events
    _redirectly.onAppInstalled.listen(_handleAppInstall);
    
    // Check for initial link on app start
    await _checkInitialLink();
  }
  
  static Future<void> _checkInitialLink() async {
    if (_hasProcessedInitialLink) return;
    
    final initialLink = await _redirectly.getInitialLink();
    if (initialLink != null) {
      await _processOnboardingLink(initialLink);
      _hasProcessedInitialLink = true;
    }
  }
  
  static void _handleAppInstall(AppInstallEvent event) {
    if (event.matched && event.link != null) {
      _processOnboardingLink(event.link!);
    }
  }
  
  static Future<void> _processOnboardingLink(LinkClickEvent event) async {
    if (event.error != null) {
      print('Onboarding link error: ${event.error}');
      return;
    }
    
    final uri = Uri.parse(event.originalUrl);
    final onboardingData = _extractOnboardingData(uri);
    
    if (onboardingData != null) {
      // Store onboarding context
      await _storeOnboardingContext(onboardingData);
      
      // Navigate to appropriate onboarding flow
      _navigateToOnboardingFlow(onboardingData);
    }
  }
  
  static Map<String, dynamic>? _extractOnboardingData(Uri uri) {
    // Extract onboarding parameters from the link
    final source = uri.queryParameters['source'];
    final campaign = uri.queryParameters['campaign'];
    final feature = uri.queryParameters['feature'];
    final referrer = uri.queryParameters['ref'];
    
    if (source != null) {
      return {
        'source': source,
        'campaign': campaign,
        'feature': feature,
        'referrer': referrer,
        'timestamp': DateTime.now().toIso8601String(),
      };
    }
    
    return null;
  }
  
  static Future<void> _storeOnboardingContext(Map<String, dynamic> data) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('onboarding_context', jsonEncode(data));
  }
  
  static void _navigateToOnboardingFlow(Map<String, dynamic> data) {
    final source = data['source'] as String;
    
    switch (source) {
      case 'marketing':
        _navigateToMarketingOnboarding(data);
        break;
      case 'referral':
        _navigateToReferralOnboarding(data);
        break;
      case 'feature':
        _navigateToFeatureOnboarding(data);
        break;
      default:
        _navigateToDefaultOnboarding();
    }
  }
  
  static void _navigateToMarketingOnboarding(Map<String, dynamic> data) {
    Get.offAllNamed('/onboarding/marketing', arguments: {
      'campaign': data['campaign'],
      'source': data['source'],
    });
  }
  
  static void _navigateToReferralOnboarding(Map<String, dynamic> data) {
    Get.offAllNamed('/onboarding/referral', arguments: {
      'referrer': data['referrer'],
      'source': data['source'],
    });
  }
  
  static void _navigateToFeatureOnboarding(Map<String, dynamic> data) {
    Get.offAllNamed('/onboarding/feature', arguments: {
      'feature': data['feature'],
      'source': data['source'],
    });
  }
  
  static void _navigateToDefaultOnboarding() {
    Get.offAllNamed('/onboarding/default');
  }
}

2. Initialize in main.dart

Initialize the onboarding service when your app starts:

dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Initialize onboarding service
  await OnboardingService.initialize();
  
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'My App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: AuthService.isLoggedIn() 
        ? HomeScreen() 
        : OnboardingWrapper(),
      routes: {
        '/onboarding/default': (context) => DefaultOnboardingScreen(),
        '/onboarding/marketing': (context) => MarketingOnboardingScreen(),
        '/onboarding/referral': (context) => ReferralOnboardingScreen(),
        '/onboarding/feature': (context) => FeatureOnboardingScreen(),
        '/home': (context) => HomeScreen(),
      },
    );
  }
}

class OnboardingWrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Map<String, dynamic>?>(
      future: _getOnboardingContext(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return SplashScreen();
        }
        
        final context = snapshot.data;
        if (context != null) {
          // Navigate to appropriate onboarding based on context
          WidgetsBinding.instance.addPostFrameCallback((_) {
            OnboardingService._navigateToOnboardingFlow(context);
          });
        }
        
        return DefaultOnboardingScreen();
      },
    );
  }
  
  Future<Map<String, dynamic>?> _getOnboardingContext() async {
    final prefs = await SharedPreferences.getInstance();
    final contextString = prefs.getString('onboarding_context');
    
    if (contextString != null) {
      return jsonDecode(contextString) as Map<String, dynamic>;
    }
    
    return null;
  }
}

Onboarding Flows

Marketing Campaign Onboarding

Create personalized onboarding experiences for users coming from marketing campaigns:

dart
class MarketingOnboardingScreen extends StatefulWidget {
  final String? campaign;
  final String? source;
  
  const MarketingOnboardingScreen({
    Key? key,
    this.campaign,
    this.source,
  }) : super(key: key);
  
  @override
  _MarketingOnboardingScreenState createState() => _MarketingOnboardingScreenState();
}

class _MarketingOnboardingScreenState extends State<MarketingOnboardingScreen> {
  int _currentStep = 0;
  final PageController _pageController = PageController();
  
  final List<OnboardingStep> _steps = [
    OnboardingStep(
      title: 'Welcome to Our App!',
      description: 'Thanks for joining us from our marketing campaign.',
      image: 'assets/onboarding/welcome.png',
    ),
    OnboardingStep(
      title: 'Discover Features',
      description: 'Explore the amazing features we have to offer.',
      image: 'assets/onboarding/features.png',
    ),
    OnboardingStep(
      title: 'Get Started',
      description: 'You're all set! Start exploring the app.',
      image: 'assets/onboarding/complete.png',
    ),
  ];
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            // Progress indicator
            LinearProgressIndicator(
              value: (_currentStep + 1) / _steps.length,
              backgroundColor: Colors.grey[300],
              valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
            ),
            
            // Onboarding content
            Expanded(
              child: PageView.builder(
                controller: _pageController,
                onPageChanged: (index) {
                  setState(() => _currentStep = index);
                },
                itemCount: _steps.length,
                itemBuilder: (context, index) {
                  final step = _steps[index];
                  return OnboardingPage(
                    step: step,
                    campaign: widget.campaign,
                    source: widget.source,
                  );
                },
              ),
            ),
            
            // Navigation buttons
            Padding(
              padding: EdgeInsets.all(24.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  if (_currentStep > 0)
                    TextButton(
                      onPressed: _previousStep,
                      child: Text('Back'),
                    )
                  else
                    SizedBox.shrink(),
                  
                  ElevatedButton(
                    onPressed: _nextStep,
                    child: Text(_currentStep == _steps.length - 1 ? 'Get Started' : 'Next'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
  
  void _previousStep() {
    if (_currentStep > 0) {
      _pageController.previousPage(
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
      );
    }
  }
  
  void _nextStep() {
    if (_currentStep < _steps.length - 1) {
      _pageController.nextPage(
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
      );
    } else {
      _completeOnboarding();
    }
  }
  
  void _completeOnboarding() {
    // Track onboarding completion
    AnalyticsService.trackEvent('onboarding_completed', {
      'source': widget.source,
      'campaign': widget.campaign,
      'flow': 'marketing',
    });
    
    // Navigate to main app
    Get.offAllNamed('/home');
  }
}

Best Practices

User Experience

  • • Keep onboarding flows short and focused
  • • Use clear, actionable language
  • • Provide visual feedback and progress indicators
  • • Allow users to skip non-essential steps
  • • Test onboarding flows on different devices
  • • Gather user feedback and iterate

Technical Implementation

  • • Handle edge cases gracefully
  • • Implement proper error handling
  • • Use analytics to track completion rates
  • • Store onboarding state persistently
  • • Optimize for performance
  • • Test deferred deep linking thoroughly
Pro Tips for Success
  • • Personalize onboarding based on the source of the user
  • • Use deferred deep linking to maintain context across the user journey
  • • A/B test different onboarding flows to optimize conversion rates
  • • Track key metrics like completion rates and time to first value
  • • Provide clear value propositions early in the onboarding process
  • • Use progressive disclosure to avoid overwhelming new users

🎉 Perfect Onboarding Experiences Await!

You now have a complete onboarding system with deferred deep linking that provides seamless user experiences. This setup will help you convert more users and improve retention rates significantly.

Related Articles