← Back to Blog

Flutter Deep Linking Localization: Universal Links, App Links, and Deferred Deep Links

flutterdeep-linkinguniversal-linksapp-linkslocalizationrouting

Flutter Deep Linking Localization: Universal Links, App Links, and Deferred Deep Links

Build deep linking systems that work seamlessly across languages. This guide covers localizing URL paths, handling language-prefixed routes, managing deferred deep links, and creating locale-aware navigation in Flutter applications.

Deep Linking Localization Challenges

Deep linking requires localization for:

  • URL paths - Localized slugs and route segments
  • Landing pages - Language-specific content on link arrival
  • Error pages - "Link expired" or "Page not found" messages
  • Onboarding flows - First-time user experience from deep links
  • Attribution tracking - Locale-aware campaign parameters

Setting Up Localized Deep Links

URL Structure Strategies

// Strategy 1: Language prefix in path
// https://app.example.com/en/products/123
// https://app.example.com/es/productos/123
// https://app.example.com/de/produkte/123

// Strategy 2: Language as query parameter
// https://app.example.com/products/123?lang=en
// https://app.example.com/products/123?lang=es

// Strategy 3: Subdomain-based
// https://en.app.example.com/products/123
// https://es.app.example.com/products/123

// Strategy 4: Accept-Language header (web only)
// Automatic detection based on browser settings

ARB File Structure for Deep Links

{
  "@@locale": "en",

  "deepLinkWelcome": "Welcome! Opening your content...",
  "@deepLinkWelcome": {
    "description": "Message shown while processing deep link"
  },

  "deepLinkExpired": "This link has expired",
  "@deepLinkExpired": {
    "description": "Error for expired deep links"
  },

  "deepLinkInvalid": "This link is no longer valid",
  "@deepLinkInvalid": {
    "description": "Error for invalid deep links"
  },

  "deepLinkNotFound": "The page you're looking for doesn't exist",
  "@deepLinkNotFound": {
    "description": "404 error for deep links"
  },

  "deepLinkLoginRequired": "Please sign in to access this content",
  "@deepLinkLoginRequired": {
    "description": "Auth required message for deep links"
  },

  "deepLinkProcessing": "Processing your link...",
  "@deepLinkProcessing": {
    "description": "Loading message for deep link processing"
  },

  "deepLinkOpenInApp": "Open in App",
  "@deepLinkOpenInApp": {
    "description": "Button to open link in app"
  },

  "deepLinkContinueInBrowser": "Continue in Browser",
  "@deepLinkContinueInBrowser": {
    "description": "Button to stay in browser"
  },

  "deepLinkShareContent": "Share this {contentType}",
  "@deepLinkShareContent": {
    "description": "Share prompt with content type",
    "placeholders": {
      "contentType": {
        "type": "String",
        "description": "Type of content being shared"
      }
    }
  }
}

Implementing Localized Deep Link Handler

Deep Link Service

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedDeepLinkService {
  static final LocalizedDeepLinkService _instance = LocalizedDeepLinkService._();
  factory LocalizedDeepLinkService() => _instance;
  LocalizedDeepLinkService._();

  // Supported locales for URL paths
  static const supportedLocalePrefixes = ['en', 'es', 'de', 'fr', 'ja', 'zh'];

  /// Parse locale from deep link URL
  static Locale? parseLocaleFromUrl(Uri uri) {
    final pathSegments = uri.pathSegments;

    if (pathSegments.isEmpty) return null;

    final firstSegment = pathSegments.first.toLowerCase();

    if (supportedLocalePrefixes.contains(firstSegment)) {
      return Locale(firstSegment);
    }

    // Check query parameter fallback
    final langParam = uri.queryParameters['lang'];
    if (langParam != null && supportedLocalePrefixes.contains(langParam)) {
      return Locale(langParam);
    }

    return null;
  }

  /// Remove locale prefix from path for routing
  static String getPathWithoutLocale(Uri uri) {
    final pathSegments = uri.pathSegments.toList();

    if (pathSegments.isEmpty) return '/';

    final firstSegment = pathSegments.first.toLowerCase();

    if (supportedLocalePrefixes.contains(firstSegment)) {
      pathSegments.removeAt(0);
    }

    return '/${pathSegments.join('/')}';
  }

  /// Build localized deep link URL
  static Uri buildLocalizedUrl({
    required String baseUrl,
    required String path,
    required Locale locale,
    Map<String, String>? queryParameters,
    bool includeLocalePrefix = true,
  }) {
    final uri = Uri.parse(baseUrl);

    String localizedPath;
    if (includeLocalePrefix) {
      localizedPath = '/${locale.languageCode}$path';
    } else {
      localizedPath = path;
    }

    return uri.replace(
      path: localizedPath,
      queryParameters: {
        if (!includeLocalePrefix) 'lang': locale.languageCode,
        ...?queryParameters,
      },
    );
  }
}

Deep Link Router Configuration

class LocalizedDeepLinkRouter {
  static GoRouter createRouter({
    required GlobalKey<NavigatorState> navigatorKey,
    required ValueNotifier<Locale> localeNotifier,
  }) {
    return GoRouter(
      navigatorKey: navigatorKey,
      initialLocation: '/',
      redirect: (context, state) {
        final uri = state.uri;

        // Extract and set locale from URL
        final urlLocale = LocalizedDeepLinkService.parseLocaleFromUrl(uri);
        if (urlLocale != null) {
          localeNotifier.value = urlLocale;
        }

        // Get path without locale prefix for routing
        final cleanPath = LocalizedDeepLinkService.getPathWithoutLocale(uri);

        // If path changed, redirect to clean path
        if (cleanPath != state.matchedLocation) {
          return cleanPath;
        }

        return null;
      },
      routes: [
        GoRoute(
          path: '/',
          builder: (context, state) => const HomeScreen(),
        ),
        GoRoute(
          path: '/product/:id',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return ProductScreen(productId: id);
          },
        ),
        GoRoute(
          path: '/invite/:code',
          builder: (context, state) {
            final code = state.pathParameters['code']!;
            return InviteHandlerScreen(inviteCode: code);
          },
        ),
        GoRoute(
          path: '/share/:type/:id',
          builder: (context, state) {
            final type = state.pathParameters['type']!;
            final id = state.pathParameters['id']!;
            return SharedContentScreen(contentType: type, contentId: id);
          },
        ),
      ],
      errorBuilder: (context, state) => LocalizedDeepLinkErrorScreen(
        error: state.error,
        uri: state.uri,
      ),
    );
  }
}

Deferred Deep Link Handling

First-Time User Experience

class DeferredDeepLinkHandler extends StatefulWidget {
  final Widget child;
  final Uri? pendingDeepLink;

  const DeferredDeepLinkHandler({
    Key? key,
    required this.child,
    this.pendingDeepLink,
  }) : super(key: key);

  @override
  State<DeferredDeepLinkHandler> createState() => _DeferredDeepLinkHandlerState();
}

class _DeferredDeepLinkHandlerState extends State<DeferredDeepLinkHandler> {
  bool _hasHandledDeepLink = false;

  @override
  void initState() {
    super.initState();
    _checkDeferredDeepLink();
  }

  Future<void> _checkDeferredDeepLink() async {
    if (_hasHandledDeepLink) return;

    // Check for stored deferred deep link
    final storedLink = await _getStoredDeepLink();

    if (storedLink != null) {
      _hasHandledDeepLink = true;
      await _handleDeferredLink(storedLink);
    }
  }

  Future<Uri?> _getStoredDeepLink() async {
    // Retrieve from secure storage or shared preferences
    final prefs = await SharedPreferences.getInstance();
    final linkString = prefs.getString('deferred_deep_link');

    if (linkString != null) {
      await prefs.remove('deferred_deep_link');
      return Uri.tryParse(linkString);
    }

    return null;
  }

  Future<void> _handleDeferredLink(Uri uri) async {
    final l10n = AppLocalizations.of(context)!;

    // Show processing dialog
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => AlertDialog(
        content: Row(
          children: [
            const CircularProgressIndicator(),
            const SizedBox(width: 16),
            Expanded(child: Text(l10n.deepLinkProcessing)),
          ],
        ),
      ),
    );

    // Extract locale and navigate
    final locale = LocalizedDeepLinkService.parseLocaleFromUrl(uri);
    if (locale != null) {
      // Update app locale
      context.read<LocaleProvider>().setLocale(locale);
    }

    // Small delay for locale to apply
    await Future.delayed(const Duration(milliseconds: 300));

    // Close dialog and navigate
    Navigator.of(context).pop();

    final path = LocalizedDeepLinkService.getPathWithoutLocale(uri);
    context.go(path);
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }
}

Store Deep Link for New Installs

class DeepLinkStorageService {
  static const _key = 'deferred_deep_link';
  static const _timestampKey = 'deferred_deep_link_timestamp';
  static const _maxAge = Duration(days: 7);

  /// Store deep link for handling after install/onboarding
  static Future<void> storeDeepLink(Uri uri) async {
    final prefs = await SharedPreferences.getInstance();

    await prefs.setString(_key, uri.toString());
    await prefs.setInt(_timestampKey, DateTime.now().millisecondsSinceEpoch);
  }

  /// Retrieve and validate stored deep link
  static Future<Uri?> retrieveDeepLink() async {
    final prefs = await SharedPreferences.getInstance();

    final linkString = prefs.getString(_key);
    final timestamp = prefs.getInt(_timestampKey);

    if (linkString == null || timestamp == null) return null;

    // Check if link is still valid
    final storedTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
    if (DateTime.now().difference(storedTime) > _maxAge) {
      await clearDeepLink();
      return null;
    }

    return Uri.tryParse(linkString);
  }

  /// Clear stored deep link
  static Future<void> clearDeepLink() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(_key);
    await prefs.remove(_timestampKey);
  }
}

Error Screens for Deep Links

Localized Error Screen

class LocalizedDeepLinkErrorScreen extends StatelessWidget {
  final Exception? error;
  final Uri uri;

  const LocalizedDeepLinkErrorScreen({
    Key? key,
    this.error,
    required this.uri,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final errorType = _getErrorType();

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                _getErrorIcon(errorType),
                size: 80,
                color: _getErrorColor(errorType),
              ),
              const SizedBox(height: 24),
              Text(
                _getErrorTitle(l10n, errorType),
                style: Theme.of(context).textTheme.headlineSmall,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 12),
              Text(
                _getErrorMessage(l10n, errorType),
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: Colors.grey[600],
                ),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 32),
              _buildActions(context, l10n, errorType),
            ],
          ),
        ),
      ),
    );
  }

  DeepLinkErrorType _getErrorType() {
    if (error is LinkExpiredException) {
      return DeepLinkErrorType.expired;
    } else if (error is AuthRequiredException) {
      return DeepLinkErrorType.authRequired;
    } else if (error is NotFoundException) {
      return DeepLinkErrorType.notFound;
    }
    return DeepLinkErrorType.invalid;
  }

  IconData _getErrorIcon(DeepLinkErrorType type) {
    switch (type) {
      case DeepLinkErrorType.expired:
        return Icons.timer_off;
      case DeepLinkErrorType.authRequired:
        return Icons.lock;
      case DeepLinkErrorType.notFound:
        return Icons.search_off;
      case DeepLinkErrorType.invalid:
        return Icons.link_off;
    }
  }

  Color _getErrorColor(DeepLinkErrorType type) {
    switch (type) {
      case DeepLinkErrorType.expired:
        return Colors.orange;
      case DeepLinkErrorType.authRequired:
        return Colors.blue;
      case DeepLinkErrorType.notFound:
        return Colors.grey;
      case DeepLinkErrorType.invalid:
        return Colors.red;
    }
  }

  String _getErrorTitle(AppLocalizations l10n, DeepLinkErrorType type) {
    switch (type) {
      case DeepLinkErrorType.expired:
        return l10n.deepLinkExpired;
      case DeepLinkErrorType.authRequired:
        return l10n.deepLinkLoginRequired;
      case DeepLinkErrorType.notFound:
        return l10n.deepLinkNotFound;
      case DeepLinkErrorType.invalid:
        return l10n.deepLinkInvalid;
    }
  }

  String _getErrorMessage(AppLocalizations l10n, DeepLinkErrorType type) {
    switch (type) {
      case DeepLinkErrorType.expired:
        return l10n.deepLinkExpiredMessage;
      case DeepLinkErrorType.authRequired:
        return l10n.deepLinkLoginRequiredMessage;
      case DeepLinkErrorType.notFound:
        return l10n.deepLinkNotFoundMessage;
      case DeepLinkErrorType.invalid:
        return l10n.deepLinkInvalidMessage;
    }
  }

  Widget _buildActions(
    BuildContext context,
    AppLocalizations l10n,
    DeepLinkErrorType type,
  ) {
    return Column(
      children: [
        if (type == DeepLinkErrorType.authRequired) ...[
          ElevatedButton(
            onPressed: () => context.go('/login'),
            child: Text(l10n.signIn),
          ),
          const SizedBox(height: 12),
        ],
        OutlinedButton(
          onPressed: () => context.go('/'),
          child: Text(l10n.goToHome),
        ),
      ],
    );
  }
}

enum DeepLinkErrorType {
  expired,
  authRequired,
  notFound,
  invalid,
}

Generating Shareable Deep Links

Localized Link Generator

class LocalizedLinkGenerator {
  final String baseUrl;

  LocalizedLinkGenerator({required this.baseUrl});

  /// Generate shareable product link
  Uri generateProductLink({
    required String productId,
    required Locale locale,
    String? campaignSource,
    String? campaignMedium,
  }) {
    return LocalizedDeepLinkService.buildLocalizedUrl(
      baseUrl: baseUrl,
      path: '/product/$productId',
      locale: locale,
      queryParameters: {
        if (campaignSource != null) 'utm_source': campaignSource,
        if (campaignMedium != null) 'utm_medium': campaignMedium,
      },
    );
  }

  /// Generate invite link with locale
  Uri generateInviteLink({
    required String inviteCode,
    required Locale locale,
    String? referrerId,
  }) {
    return LocalizedDeepLinkService.buildLocalizedUrl(
      baseUrl: baseUrl,
      path: '/invite/$inviteCode',
      locale: locale,
      queryParameters: {
        if (referrerId != null) 'ref': referrerId,
      },
    );
  }

  /// Generate content share link
  Uri generateShareLink({
    required String contentType,
    required String contentId,
    required Locale locale,
  }) {
    return LocalizedDeepLinkService.buildLocalizedUrl(
      baseUrl: baseUrl,
      path: '/share/$contentType/$contentId',
      locale: locale,
    );
  }
}

Share Link Widget

class LocalizedShareLinkWidget extends StatelessWidget {
  final String contentType;
  final String contentId;
  final String? title;

  const LocalizedShareLinkWidget({
    Key? key,
    required this.contentType,
    required this.contentId,
    this.title,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);

    return ElevatedButton.icon(
      onPressed: () => _shareLink(context, locale, l10n),
      icon: const Icon(Icons.share),
      label: Text(l10n.deepLinkShareContent(_getContentTypeLabel(l10n))),
    );
  }

  String _getContentTypeLabel(AppLocalizations l10n) {
    switch (contentType) {
      case 'product':
        return l10n.product;
      case 'article':
        return l10n.article;
      case 'profile':
        return l10n.profile;
      default:
        return l10n.content;
    }
  }

  Future<void> _shareLink(
    BuildContext context,
    Locale locale,
    AppLocalizations l10n,
  ) async {
    final linkGenerator = LocalizedLinkGenerator(
      baseUrl: 'https://app.example.com',
    );

    final shareUri = linkGenerator.generateShareLink(
      contentType: contentType,
      contentId: contentId,
      locale: locale,
    );

    await Share.share(
      shareUri.toString(),
      subject: title ?? l10n.shareSubject,
    );
  }
}

Universal Links and App Links Configuration

iOS Universal Links (apple-app-site-association)

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.example.app",
        "paths": [
          "/en/*",
          "/es/*",
          "/de/*",
          "/fr/*",
          "/product/*",
          "/invite/*",
          "/share/*",
          "NOT /api/*"
        ]
      }
    ]
  }
}

Android App Links (assetlinks.json)

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "YOUR_SHA256_FINGERPRINT"
      ]
    }
  }
]

Android Manifest Intent Filters

<activity android:name=".MainActivity">
  <!-- Deep links for all supported locales -->
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="app.example.com" />
    <data android:pathPrefix="/en/" />
    <data android:pathPrefix="/es/" />
    <data android:pathPrefix="/de/" />
    <data android:pathPrefix="/fr/" />
    <data android:pathPrefix="/product/" />
    <data android:pathPrefix="/invite/" />
    <data android:pathPrefix="/share/" />
  </intent-filter>

  <!-- Custom scheme for development -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="exampleapp" />
  </intent-filter>
</activity>

Localized Path Segments

Translatable Route Paths

class LocalizedRoutes {
  // Route path translations
  static const Map<String, Map<String, String>> _pathTranslations = {
    'products': {
      'en': 'products',
      'es': 'productos',
      'de': 'produkte',
      'fr': 'produits',
    },
    'categories': {
      'en': 'categories',
      'es': 'categorias',
      'de': 'kategorien',
      'fr': 'categories',
    },
    'about': {
      'en': 'about',
      'es': 'acerca-de',
      'de': 'ueber-uns',
      'fr': 'a-propos',
    },
    'contact': {
      'en': 'contact',
      'es': 'contacto',
      'de': 'kontakt',
      'fr': 'contact',
    },
  };

  /// Get localized path segment
  static String getLocalizedPath(String key, Locale locale) {
    return _pathTranslations[key]?[locale.languageCode] ??
        _pathTranslations[key]?['en'] ??
        key;
  }

  /// Get canonical key from localized path
  static String? getCanonicalKey(String localizedPath) {
    for (final entry in _pathTranslations.entries) {
      if (entry.value.values.contains(localizedPath)) {
        return entry.key;
      }
    }
    return null;
  }

  /// Build full localized URL path
  static String buildPath(List<String> segments, Locale locale) {
    final localizedSegments = segments.map(
      (segment) => getLocalizedPath(segment, locale),
    );
    return '/${locale.languageCode}/${localizedSegments.join('/')}';
  }
}

Testing Deep Link Localization

Integration Tests

void main() {
  group('LocalizedDeepLinkService', () {
    test('parses locale from URL prefix', () {
      final uri = Uri.parse('https://app.example.com/es/product/123');
      final locale = LocalizedDeepLinkService.parseLocaleFromUrl(uri);

      expect(locale, equals(const Locale('es')));
    });

    test('parses locale from query parameter', () {
      final uri = Uri.parse('https://app.example.com/product/123?lang=de');
      final locale = LocalizedDeepLinkService.parseLocaleFromUrl(uri);

      expect(locale, equals(const Locale('de')));
    });

    test('removes locale prefix from path', () {
      final uri = Uri.parse('https://app.example.com/fr/product/123');
      final path = LocalizedDeepLinkService.getPathWithoutLocale(uri);

      expect(path, equals('/product/123'));
    });

    test('builds localized URL correctly', () {
      final uri = LocalizedDeepLinkService.buildLocalizedUrl(
        baseUrl: 'https://app.example.com',
        path: '/product/123',
        locale: const Locale('ja'),
      );

      expect(uri.toString(), equals('https://app.example.com/ja/product/123'));
    });
  });
}

Best Practices

  1. Use consistent URL structure - Pick one locale strategy and stick with it
  2. Handle missing locales gracefully - Fall back to default language
  3. Validate deep links - Check expiration, authentication, and validity
  4. Store deferred links - Handle links that arrive before onboarding
  5. Test on real devices - Universal/App Links behave differently in simulators
  6. Monitor deep link analytics - Track success rates by locale

Conclusion

Localized deep linking creates seamless experiences for international users. By implementing proper locale detection, translated paths, and localized error handling, you ensure that users land on the right content in their preferred language.

Remember to thoroughly test your deep linking implementation across all supported locales and platforms, paying special attention to deferred deep links and edge cases like expired or invalid links.