← Back to Blog

Flutter Web Localization: Complete Guide to Multilingual Web Apps

flutterweblocalizationseomultilingualhreflang

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/about or ?lang=en patterns
  • 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.

Related Resources