← Back to Blog

Flutter Web Locale in URL: /en/, /fr/ with go_router

flutter webgo_routerlocalizationgen-l10ndeep links

Flutter Web Locale in URL: /en/, /fr/ with go_router

You want example.com/fr/settings to open the French settings screen, and you want the same link to work when someone pastes it into Slack. Most go_router tutorials stop at GoRoute(path: '/settings') and never connect routing to MaterialApp.router's locale. This post puts the whole pattern in one place:

  • a top-level ShellRoute that holds a /:lang route, with every page nested under it
  • the :lang segment drives MaterialApp.router's locale
  • a redirect that sends / and unsupported codes to the device's resolved locale
  • a language switcher that rewrites only the prefix and keeps the rest of the path
  • the server rewrite that stops /fr/settings from returning a 404

The code targets a current go_router (18.x at the time of writing) and Flutter's built-in gen-l10n.

1. Dependencies and path URLs

Flutter web uses hash URLs (/#/fr/settings) by default. For clean, shareable paths, switch to the path URL strategy. It lives in flutter_web_plugins, which ships with the Flutter SDK, so you declare it as an SDK dependency the same way you declare flutter_localizations:

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  flutter_web_plugins:
    sdk: flutter
  go_router: ^18.1.0
  intl: any

flutter:
  generate: true

You can write those lines by hand or add them from the command line with flutter pub add, for example flutter pub add 'flutter_web_plugins:{"sdk":"flutter"}'. That is the same descriptor form Flutter's internationalization guide uses for flutter_localizations.

With generate: true and an l10n.yaml pointing at lib/l10n, gen-l10n produces AppLocalizations with a static supportedLocales list and a localizationsDelegates list. The router builds on both of these.

2. The router: ShellRoute plus a :lang segment

Every real page sits under /:lang. The ShellRoute gives you one place for shared chrome, such as an app bar with the language menu.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import 'l10n/app_localizations.dart';

final supportedLangs = AppLocalizations.supportedLocales
    .map((l) => l.languageCode)
    .toSet();

/// The best supported language for this browser, using Flutter's own
/// matching rules (exact match, then language code, then first supported).
String deviceLang() => basicLocaleListResolution(
      WidgetsBinding.instance.platformDispatcher.locales,
      AppLocalizations.supportedLocales,
    ).languageCode;

final _looksLikeLang = RegExp(r'^[a-z]{2}$');

String? localeRedirect(BuildContext context, GoRouterState state) {
  final uri = state.uri;
  final segments = uri.pathSegments.where((s) => s.isNotEmpty).toList();

  if (segments.isEmpty) return '/${deviceLang()}';
  if (supportedLangs.contains(segments.first)) return null;

  // "/de/settings" when German isn't shipped: swap the code.
  // "/settings" with no prefix at all: add one.
  final rest =
      _looksLikeLang.hasMatch(segments.first) ? segments.skip(1) : segments;
  return uri.replace(path: '/${[deviceLang(), ...rest].join('/')}').toString();
}

final router = GoRouter(
  redirect: localeRedirect,
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/:lang',
          builder: (context, state) => const HomePage(),
          routes: [
            GoRoute(
              path: 'settings',
              builder: (context, state) => const SettingsPage(),
            ),
            GoRoute(
              path: 'products/:id',
              builder: (context, state) =>
                  ProductPage(id: state.pathParameters['id']!),
            ),
          ],
        ),
      ],
    ),
  ],
);

A few details matter here.

The redirect runs on every navigation, so it has to return null quickly once the URL is valid. Otherwise you get a redirect loop.

uri.replace(path: ...) keeps the query string, so /settings?tab=billing becomes /en/settings?tab=billing and nothing is lost.

The two-letter regex is a heuristic. It decides whether the first segment is a bad language code (replace it) or a page name (prepend a code). Keep your top-level page names longer than two letters, or swap the regex for an explicit set of language codes you have ever shipped.

basicLocaleListResolution is the same fallback logic MaterialApp uses internally, so the redirect target matches what the app would have picked without a URL prefix.

3. Let the URL drive MaterialApp.router's locale

You could wrap the shell's child in Localizations.override, but dialogs and bottom sheets on the root navigator sit outside the shell and would miss the override. It is cleaner to feed the locale into MaterialApp.router itself. GoRouterDelegate is a ChangeNotifier, and its currentConfiguration.uri is the current location, so you can rebuild the app when the route changes:

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy();
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: router.routerDelegate,
      builder: (context, _) {
        final segments =
            router.routerDelegate.currentConfiguration.uri.pathSegments;
        final lang = segments.isNotEmpty &&
                supportedLangs.contains(segments.first)
            ? segments.first
            : null;

        return MaterialApp.router(
          routerConfig: router,
          locale: lang == null ? null : Locale(lang),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
        );
      },
    );
  }
}

When lang is null (the first frame, before the redirect lands), locale falls back to normal device resolution, so nothing flashes in the wrong language.

Because the locale comes from the URL, there is no setState or provider to keep in sync with the route. If you previously switched languages through a notifier and saw stale strings, the same root cause is covered in Flutter Language Switch Only Works After Restart.

If you ship regional variants such as pt_BR, note that Locale('pt') resolves to your pt_BR translations only when no plain pt exists. If that matching behaves oddly, read Flutter pt_BR Shows English? Fix Locale Fallback before you start putting region codes in URLs.

4. A language switcher that keeps the path

Switching from French to English on /fr/products/42?ref=mail should land on /en/products/42?ref=mail, not on the home page. Replace only the first segment:

class LanguageMenu extends StatelessWidget {
  const LanguageMenu({super.key});

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<String>(
      icon: const Icon(Icons.language),
      onSelected: (code) {
        final router = GoRouter.of(context);
        final uri = router.routerDelegate.currentConfiguration.uri;
        final segments = [...uri.pathSegments];
        if (segments.isEmpty) {
          segments.add(code);
        } else {
          segments[0] = code;
        }
        router.go(uri.replace(path: '/${segments.join('/')}').toString());
      },
      itemBuilder: (context) => [
        for (final code in supportedLangs)
          PopupMenuItem(value: code, child: Text(code.toUpperCase())),
      ],
    );
  }
}

class AppShell extends StatelessWidget {
  const AppShell({super.key, required this.child});
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(AppLocalizations.of(context)!.appTitle),
        actions: const [LanguageMenu()],
      ),
      body: child,
    );
  }
}

Use go, not push. go replaces the location, so the browser address bar and history show the new language URL.

Internal links keep the current language

Hard-coding context.go('/settings') would hit the redirect and switch the user to the device language. Add a small helper that prefixes the active language:

extension LocalizedGo on BuildContext {
  void goLocalized(String path) =>
      go('/${Localizations.localeOf(this).languageCode}$path');
}

// Usage
context.goLocalized('/products/42');

5. Server rewrites so /fr/settings doesn't 404

flutter run -d chrome handles deep paths for you, so everything works locally. In production, a static host looks for a file at /fr/settings, finds none, and returns a 404. Path URLs use the browser History API, so the server must send every unknown path to index.html.

Firebase Hosting (firebase.json), which you get by answering yes to "Configure as a single-page app":

{
  "hosting": {
    "public": "build/web",
    "rewrites": [{ "source": "**", "destination": "/index.html" }]
  }
}

Nginx:

location / {
  try_files $uri $uri/ /index.html;
}

Netlify (build/web/_redirects):

/*  /index.html  200

Also check <base href="/"> in web/index.html. It must stay absolute. A relative base href makes /fr/settings try to load main.dart.js from /fr/. If you host under a subfolder, set it to that folder, for example <base href="/app/">.

What this does (and doesn't) do for SEO

Per-language URLs give every language a stable, shareable address, and go_router's deep linking makes each one open the right screen. That covers sharing, bookmarks, marketing links and analytics split by language. Flutter web still renders the UI itself, so don't expect crawlers to index each page's text the way they index server-rendered HTML. If search traffic matters, keep a crawlable marketing site and link into the app's language paths.

Keep the ARB files behind those URLs in shape

Every new /xx/ prefix is a full set of app_xx.arb strings that has to stay complete. FlutterLocalisation gives you an ARB editor for managing translations across all your locales in a UI instead of raw JSON. It also validates ICU plurals, flagging a locale that is missing a plural category the language needs, such as few or many for Polish or Arabic. Plans are on the pricing page.

Try FlutterLocalisation free and ship your next language URL with complete translations behind it.