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
- Use consistent URL structure - Pick one locale strategy and stick with it
- Handle missing locales gracefully - Fall back to default language
- Validate deep links - Check expiration, authentication, and validity
- Store deferred links - Handle links that arrive before onboarding
- Test on real devices - Universal/App Links behave differently in simulators
- 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.