Flutter Web Localization: Complete Guide to Multilingual Web Apps
Building a Flutter web app that serves users globally? You'll need proper localization. Flutter Web brings unique challenges—and opportunities—for internationalization that differ from mobile apps.
This guide covers everything you need to know about localizing Flutter Web applications, from SEO considerations to browser locale detection.
Why Flutter Web Localization Is Different
Flutter Web runs in browsers, which means:
- SEO matters: Search engines need to index your localized content
- URL structure: Users expect
/en/aboutor?lang=enpatterns - Browser detection: The browser provides locale information differently
- Initial load: No splash screen to hide translation loading
- Deep linking: Users share localized URLs directly
Setting Up Flutter Web Localization
Step 1: Configure Your Project
First, ensure your pubspec.yaml has the required dependencies:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.18.0
flutter:
generate: true
Create l10n.yaml in your project root:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false
Step 2: Detect Browser Locale
Flutter Web can access the browser's preferred languages:
import 'dart:html' as html;
class WebLocaleDetector {
static Locale detectBrowserLocale() {
// Get browser's preferred languages
final languages = html.window.navigator.languages;
if (languages != null && languages.isNotEmpty) {
final primaryLanguage = languages.first;
return _parseLocale(primaryLanguage);
}
// Fallback to navigator.language
final language = html.window.navigator.language;
return _parseLocale(language ?? 'en');
}
static Locale _parseLocale(String languageTag) {
final parts = languageTag.split('-');
if (parts.length >= 2) {
return Locale(parts[0], parts[1]);
}
return Locale(parts[0]);
}
static List<Locale> getSupportedBrowserLocales() {
final languages = html.window.navigator.languages ?? [];
return languages.map(_parseLocale).toList();
}
}
Step 3: URL-Based Locale Routing
For SEO and user experience, use URL-based locale selection:
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) {
// Extract locale from URL
final locale = _extractLocaleFromPath(state.uri.path);
return LocaleWrapper(locale: locale, child: child);
},
routes: [
GoRoute(
path: '/:locale',
builder: (context, state) => HomePage(),
routes: [
GoRoute(
path: 'about',
builder: (context, state) => AboutPage(),
),
GoRoute(
path: 'pricing',
builder: (context, state) => PricingPage(),
),
],
),
],
),
],
redirect: (context, state) {
// Redirect root to detected locale
if (state.uri.path == '/') {
final locale = WebLocaleDetector.detectBrowserLocale();
return '/${locale.languageCode}';
}
return null;
},
);
Locale _extractLocaleFromPath(String path) {
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
if (segments.isNotEmpty) {
final localeCode = segments.first;
if (['en', 'es', 'fr', 'de', 'ar'].contains(localeCode)) {
return Locale(localeCode);
}
}
return const Locale('en');
}
Step 4: SEO-Optimized Localization
For search engines to properly index your multilingual content:
import 'dart:html' as html;
class WebSeoHelper {
static void updateMetaTags({
required String title,
required String description,
required String locale,
required String canonicalUrl,
}) {
// Update document title
html.document.title = title;
// Update or create meta tags
_updateMetaTag('description', description);
_updateMetaTag('og:title', title);
_updateMetaTag('og:description', description);
_updateMetaTag('og:locale', locale);
// Update canonical URL
_updateLinkTag('canonical', canonicalUrl);
// Add hreflang tags for language alternatives
_updateHreflangTags(canonicalUrl);
}
static void _updateMetaTag(String name, String content) {
var meta = html.document.querySelector('meta[name="$name"]')
?? html.document.querySelector('meta[property="$name"]');
if (meta == null) {
meta = html.MetaElement()
..name = name
..content = content;
html.document.head?.append(meta);
} else {
(meta as html.MetaElement).content = content;
}
}
static void _updateLinkTag(String rel, String href) {
var link = html.document.querySelector('link[rel="$rel"]');
if (link == null) {
link = html.LinkElement()
..rel = rel
..href = href;
html.document.head?.append(link);
} else {
(link as html.LinkElement).href = href;
}
}
static void _updateHreflangTags(String baseUrl) {
// Remove existing hreflang tags
html.document.querySelectorAll('link[hreflang]').forEach((e) => e.remove());
// Add hreflang for each supported locale
final locales = ['en', 'es', 'fr', 'de', 'ar'];
for (final locale in locales) {
final link = html.LinkElement()
..rel = 'alternate'
..setAttribute('hreflang', locale)
..href = baseUrl.replaceFirst(RegExp(r'/[a-z]{2}/'), '/$locale/');
html.document.head?.append(link);
}
// Add x-default
final defaultLink = html.LinkElement()
..rel = 'alternate'
..setAttribute('hreflang', 'x-default')
..href = baseUrl.replaceFirst(RegExp(r'/[a-z]{2}/'), '/en/');
html.document.head?.append(defaultLink);
}
}
Handling RTL in Flutter Web
Right-to-left languages need special attention in web apps:
class LocaleWrapper extends StatelessWidget {
final Locale locale;
final Widget child;
const LocaleWrapper({
required this.locale,
required this.child,
});
@override
Widget build(BuildContext context) {
final isRtl = ['ar', 'he', 'fa', 'ur'].contains(locale.languageCode);
return Directionality(
textDirection: isRtl ? TextDirection.rtl : TextDirection.ltr,
child: MaterialApp(
locale: locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
builder: (context, child) {
// Update HTML dir attribute for browser
_updateHtmlDirection(isRtl);
return child!;
},
home: child,
),
);
}
void _updateHtmlDirection(bool isRtl) {
html.document.documentElement?.setAttribute(
'dir',
isRtl ? 'rtl' : 'ltr',
);
}
}
Language Switcher for Web
Create a user-friendly language switcher:
class WebLanguageSwitcher extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentLocale = Localizations.localeOf(context);
return PopupMenuButton<Locale>(
initialValue: currentLocale,
onSelected: (locale) => _switchLanguage(context, locale),
itemBuilder: (context) => [
_buildMenuItem('en', 'English', '🇺🇸'),
_buildMenuItem('es', 'Español', '🇪🇸'),
_buildMenuItem('fr', 'Français', '🇫🇷'),
_buildMenuItem('de', 'Deutsch', '🇩🇪'),
_buildMenuItem('ar', 'العربية', '🇸🇦'),
],
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_getLanguageName(currentLocale.languageCode)),
const Icon(Icons.arrow_drop_down),
],
),
);
}
PopupMenuItem<Locale> _buildMenuItem(
String code,
String name,
String flag,
) {
return PopupMenuItem(
value: Locale(code),
child: Row(
children: [
Text(flag, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(name),
],
),
);
}
void _switchLanguage(BuildContext context, Locale locale) {
// Update URL to reflect new locale
final currentPath = GoRouterState.of(context).uri.path;
final newPath = currentPath.replaceFirst(
RegExp(r'^/[a-z]{2}'),
'/${locale.languageCode}',
);
// Save preference
html.window.localStorage['preferred_locale'] = locale.languageCode;
// Navigate to new URL
context.go(newPath);
}
String _getLanguageName(String code) {
return {
'en': 'English',
'es': 'Español',
'fr': 'Français',
'de': 'Deutsch',
'ar': 'العربية',
}[code] ?? 'English';
}
}
Loading Translations Efficiently
For Flutter Web, minimize initial load time:
class LazyLocalizationLoader {
static final Map<String, Map<String, String>> _cache = {};
static Future<Map<String, String>> loadTranslations(String locale) async {
// Return cached translations if available
if (_cache.containsKey(locale)) {
return _cache[locale]!;
}
// Load translations from JSON file
final response = await http.get(
Uri.parse('/assets/translations/$locale.json'),
);
if (response.statusCode == 200) {
final translations = Map<String, String>.from(
json.decode(response.body),
);
_cache[locale] = translations;
return translations;
}
throw Exception('Failed to load translations for $locale');
}
static void preloadCommonLocales() {
// Preload most common locales in background
['en', 'es'].forEach((locale) {
loadTranslations(locale);
});
}
}
Persisting Language Preference
Store user's language preference in the browser:
class WebLocaleStorage {
static const _key = 'user_locale';
static Locale? getSavedLocale() {
final saved = html.window.localStorage[_key];
if (saved != null && saved.isNotEmpty) {
return Locale(saved);
}
return null;
}
static void saveLocale(Locale locale) {
html.window.localStorage[_key] = locale.languageCode;
}
static Locale getInitialLocale() {
// 1. Check saved preference
final saved = getSavedLocale();
if (saved != null) return saved;
// 2. Check URL parameter
final urlLocale = _getLocaleFromUrl();
if (urlLocale != null) return urlLocale;
// 3. Use browser locale
return WebLocaleDetector.detectBrowserLocale();
}
static Locale? _getLocaleFromUrl() {
final path = html.window.location.pathname ?? '';
final match = RegExp(r'^/([a-z]{2})(/|$)').firstMatch(path);
if (match != null) {
return Locale(match.group(1)!);
}
return null;
}
}
Best Practices for Flutter Web Localization
1. Use Server-Side Rendering (SSR) When Possible
For better SEO, consider Flutter's experimental SSR or generate static pages:
// Generate static HTML for each locale during build
void generateStaticPages() {
for (final locale in supportedLocales) {
generatePage(locale, 'index.html');
generatePage(locale, 'about.html');
generatePage(locale, 'pricing.html');
}
}
2. Implement Proper Loading States
class LocalizedPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder<AppLocalizations>(
future: AppLocalizations.delegate.load(
Localizations.localeOf(context),
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return ErrorWidget(snapshot.error!);
}
return YourPageContent();
},
);
}
}
3. Handle Locale in Analytics
Track language usage for insights:
class LocalizedAnalytics {
static void trackPageView(String pageName, Locale locale) {
// Google Analytics 4 example
js.context.callMethod('gtag', [
'event',
'page_view',
js.JsObject.jsify({
'page_title': pageName,
'page_location': html.window.location.href,
'language': locale.languageCode,
}),
]);
}
}
Common Pitfalls to Avoid
1. Don't Forget the HTML Lang Attribute
void updateHtmlLang(String languageCode) {
html.document.documentElement?.setAttribute('lang', languageCode);
}
2. Handle Font Loading for Different Scripts
class WebFontLoader {
static Future<void> loadFontsForLocale(String locale) async {
if (['ar', 'fa', 'ur'].contains(locale)) {
// Load Arabic-script fonts
await _loadFont('Noto Sans Arabic',
'https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic');
}
if (['zh', 'ja', 'ko'].contains(locale)) {
// Load CJK fonts
await _loadFont('Noto Sans CJK',
'https://fonts.googleapis.com/css2?family=Noto+Sans+SC');
}
}
static Future<void> _loadFont(String name, String url) async {
final link = html.LinkElement()
..rel = 'stylesheet'
..href = url;
html.document.head?.append(link);
}
}
3. Test All Locales Before Deploy
# Build for web and test each locale
flutter build web
# Test: localhost:8080/en/, localhost:8080/es/, etc.
Conclusion
Flutter Web localization requires extra consideration for SEO, URL structure, and browser integration. By implementing URL-based routing, proper meta tags, and browser locale detection, you can create a truly international web application.
Key takeaways:
- Use URL-based locale routing for SEO
- Implement hreflang tags for search engines
- Detect and respect browser language preferences
- Persist user language choices in localStorage
- Handle RTL layouts at both Flutter and HTML levels
Start with the basics, then progressively enhance your localization as your user base grows internationally.