← Back to Blog

Flutter Push Notifications Localization: Complete Guide to Multilingual Notifications

flutternotificationsfcmlocalizationpush-notificationsfirebase

Flutter Push Notifications Localization: Complete Guide to Multilingual Notifications

Push notifications are one of the most effective ways to re-engage users, but they only work when users understand them. Sending notifications in the wrong language leads to confusion, uninstalls, and lost engagement. This guide covers everything you need to know about localizing push notifications in Flutter, from Firebase Cloud Messaging (FCM) to local notifications.

Why Notification Localization Matters

Consider these statistics:

  • 72% of users prefer receiving notifications in their native language
  • Localized notifications see 2-3x higher open rates than generic English messages
  • Users who receive notifications in their language have 40% higher retention at 30 days
  • Poorly localized notifications are a top reason for disabling notifications entirely

Types of Notifications in Flutter

Before diving into localization, understand the two types:

Type Source Localization Method
Push Notifications Server via FCM/APNs Server-side localization
Local Notifications App itself Client-side localization

Each requires a different localization strategy.

Project Setup

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  firebase_core: ^2.24.2
  firebase_messaging: ^14.7.10
  flutter_local_notifications: ^16.3.0
  shared_preferences: ^2.2.2

flutter:
  generate: true

Part 1: Local Notifications Localization

Local notifications are scheduled by your app and are the easiest to localize since you have full control.

Setting Up flutter_local_notifications

// lib/services/local_notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter/material.dart';

class LocalNotificationService {
  static final FlutterLocalNotificationsPlugin _plugin =
      FlutterLocalNotificationsPlugin();

  static Future<void> initialize() async {
    const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );

    const settings = InitializationSettings(
      android: androidSettings,
      iOS: iosSettings,
    );

    await _plugin.initialize(settings);
  }

  // Request permissions (especially important for iOS)
  static Future<bool> requestPermissions() async {
    final android = _plugin.resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>();
    final iOS = _plugin.resolvePlatformSpecificImplementation<
        IOSFlutterLocalNotificationsPlugin>();

    if (android != null) {
      await android.requestNotificationsPermission();
    }

    if (iOS != null) {
      await iOS.requestPermissions(
        alert: true,
        badge: true,
        sound: true,
      );
    }

    return true;
  }
}

Creating Localized Notification Content

// lib/services/notification_content.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

/// Notification content that supports localization
class NotificationContent {
  final String titleKey;
  final String bodyKey;
  final Map<String, String>? titleArgs;
  final Map<String, String>? bodyArgs;

  const NotificationContent({
    required this.titleKey,
    required this.bodyKey,
    this.titleArgs,
    this.bodyArgs,
  });

  /// Get localized title
  String getTitle(AppLocalizations l10n) {
    return _interpolate(_getTranslation(l10n, titleKey), titleArgs);
  }

  /// Get localized body
  String getBody(AppLocalizations l10n) {
    return _interpolate(_getTranslation(l10n, bodyKey), bodyArgs);
  }

  String _getTranslation(AppLocalizations l10n, String key) {
    // Map keys to actual translations
    switch (key) {
      case 'notification_reminder_title':
        return l10n.notificationReminderTitle;
      case 'notification_reminder_body':
        return l10n.notificationReminderBody;
      case 'notification_order_title':
        return l10n.notificationOrderTitle;
      case 'notification_order_body':
        return l10n.notificationOrderBody;
      case 'notification_sale_title':
        return l10n.notificationSaleTitle;
      case 'notification_sale_body':
        return l10n.notificationSaleBody;
      default:
        return key;
    }
  }

  String _interpolate(String template, Map<String, String>? args) {
    if (args == null) return template;

    var result = template;
    args.forEach((key, value) {
      result = result.replaceAll('{$key}', value);
    });
    return result;
  }
}

ARB Files for Notifications

// lib/l10n/app_en.arb
{
  "@@locale": "en",

  "notificationReminderTitle": "Don't forget!",
  "@notificationReminderTitle": {
    "description": "Title for reminder notifications"
  },

  "notificationReminderBody": "You have {count} items waiting in your cart",
  "@notificationReminderBody": {
    "description": "Body for cart reminder notification",
    "placeholders": {
      "count": {
        "type": "int",
        "example": "3"
      }
    }
  },

  "notificationOrderTitle": "Order Shipped!",
  "@notificationOrderTitle": {
    "description": "Title for order shipped notification"
  },

  "notificationOrderBody": "Your order #{orderId} is on its way",
  "@notificationOrderBody": {
    "description": "Body for order shipped notification",
    "placeholders": {
      "orderId": {
        "type": "String",
        "example": "12345"
      }
    }
  },

  "notificationSaleTitle": "Flash Sale!",
  "@notificationSaleTitle": {
    "description": "Title for sale notification"
  },

  "notificationSaleBody": "{discount}% off everything for the next {hours} hours",
  "@notificationSaleBody": {
    "description": "Body for sale notification",
    "placeholders": {
      "discount": {
        "type": "int",
        "example": "50"
      },
      "hours": {
        "type": "int",
        "example": "24"
      }
    }
  }
}
// lib/l10n/app_es.arb
{
  "@@locale": "es",

  "notificationReminderTitle": "No lo olvides!",
  "notificationReminderBody": "Tienes {count} articulos esperando en tu carrito",

  "notificationOrderTitle": "Pedido Enviado!",
  "notificationOrderBody": "Tu pedido #{orderId} esta en camino",

  "notificationSaleTitle": "Oferta Relampago!",
  "notificationSaleBody": "{discount}% de descuento en todo por las proximas {hours} horas"
}
// lib/l10n/app_de.arb
{
  "@@locale": "de",

  "notificationReminderTitle": "Nicht vergessen!",
  "notificationReminderBody": "Du hast {count} Artikel in deinem Warenkorb",

  "notificationOrderTitle": "Bestellung versendet!",
  "notificationOrderBody": "Deine Bestellung #{orderId} ist unterwegs",

  "notificationSaleTitle": "Blitzverkauf!",
  "notificationSaleBody": "{discount}% Rabatt auf alles fur die nachsten {hours} Stunden"
}

Showing Localized Local Notifications

// lib/services/local_notification_service.dart (continued)
extension LocalNotificationServiceExtension on LocalNotificationService {
  /// Show a localized notification
  static Future<void> showLocalized({
    required BuildContext context,
    required int id,
    required NotificationContent content,
    String? payload,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    final androidDetails = AndroidNotificationDetails(
      'default_channel',
      l10n.notificationChannelName, // Localized channel name
      channelDescription: l10n.notificationChannelDescription,
      importance: Importance.high,
      priority: Priority.high,
    );

    final iosDetails = const DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
    );

    final details = NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );

    await _plugin.show(
      id,
      content.getTitle(l10n),
      content.getBody(l10n),
      details,
      payload: payload,
    );
  }

  /// Schedule a localized notification
  static Future<void> scheduleLocalized({
    required BuildContext context,
    required int id,
    required NotificationContent content,
    required DateTime scheduledDate,
    String? payload,
  }) async {
    final l10n = AppLocalizations.of(context)!;

    final androidDetails = AndroidNotificationDetails(
      'scheduled_channel',
      l10n.scheduledNotificationChannelName,
      channelDescription: l10n.scheduledNotificationChannelDescription,
      importance: Importance.high,
      priority: Priority.high,
    );

    final details = NotificationDetails(
      android: androidDetails,
      iOS: const DarwinNotificationDetails(),
    );

    await _plugin.zonedSchedule(
      id,
      content.getTitle(l10n),
      content.getBody(l10n),
      tz.TZDateTime.from(scheduledDate, tz.local),
      details,
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      payload: payload,
    );
  }
}

Using Localized Notifications

// Example usage in your app
class CartPage extends StatelessWidget {
  Future<void> _scheduleCartReminder(BuildContext context) async {
    final content = NotificationContent(
      titleKey: 'notification_reminder_title',
      bodyKey: 'notification_reminder_body',
      bodyArgs: {'count': '3'},
    );

    await LocalNotificationService.scheduleLocalized(
      context: context,
      id: 1,
      content: content,
      scheduledDate: DateTime.now().add(const Duration(hours: 24)),
      payload: 'cart_reminder',
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () => _scheduleCartReminder(context),
          child: Text(AppLocalizations.of(context)!.remindMeLater),
        ),
      ),
    );
  }
}

Part 2: Push Notifications with Firebase Cloud Messaging

Push notifications come from your server, so localization happens server-side. However, Flutter can help by sending user locale preferences to your backend.

Tracking User Locale on Server

// lib/services/fcm_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:ui';

class FCMService {
  static final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  /// Initialize FCM and register locale with server
  static Future<void> initialize() async {
    // Request permission
    await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    // Get and save FCM token
    final token = await _messaging.getToken();
    if (token != null) {
      await _registerTokenWithLocale(token);
    }

    // Listen for token refresh
    _messaging.onTokenRefresh.listen(_registerTokenWithLocale);
  }

  /// Register FCM token with user's locale on your server
  static Future<void> _registerTokenWithLocale(String token) async {
    final prefs = await SharedPreferences.getInstance();
    final locale = prefs.getString('user_locale') ??
        PlatformDispatcher.instance.locale.languageCode;

    // Send to your backend
    await _sendToServer(
      endpoint: '/api/devices/register',
      body: {
        'fcm_token': token,
        'locale': locale,
        'platform': Platform.isIOS ? 'ios' : 'android',
      },
    );
  }

  /// Update locale on server when user changes language
  static Future<void> updateLocale(String newLocale) async {
    final token = await _messaging.getToken();
    if (token == null) return;

    await _sendToServer(
      endpoint: '/api/devices/update-locale',
      body: {
        'fcm_token': token,
        'locale': newLocale,
      },
    );
  }

  static Future<void> _sendToServer({
    required String endpoint,
    required Map<String, dynamic> body,
  }) async {
    // Implement your API call here
  }
}

Server-Side Localization (Node.js Example)

Your server needs to store translations and send the right one:

// server/notifications.js
const admin = require('firebase-admin');

// Notification translations
const translations = {
  en: {
    order_shipped_title: 'Order Shipped!',
    order_shipped_body: 'Your order #{orderId} is on its way',
    sale_title: 'Flash Sale!',
    sale_body: '{discount}% off everything for {hours} hours',
  },
  es: {
    order_shipped_title: 'Pedido Enviado!',
    order_shipped_body: 'Tu pedido #{orderId} esta en camino',
    sale_title: 'Oferta Relampago!',
    sale_body: '{discount}% de descuento en todo por {hours} horas',
  },
  de: {
    order_shipped_title: 'Bestellung versendet!',
    order_shipped_body: 'Deine Bestellung #{orderId} ist unterwegs',
    sale_title: 'Blitzverkauf!',
    sale_body: '{discount}% Rabatt auf alles fur {hours} Stunden',
  },
};

function getTranslation(locale, key, params = {}) {
  const localeTranslations = translations[locale] || translations['en'];
  let text = localeTranslations[key] || translations['en'][key] || key;

  // Replace placeholders
  Object.entries(params).forEach(([param, value]) => {
    text = text.replace(`{${param}}`, value);
  });

  return text;
}

async function sendLocalizedNotification(userId, notificationKey, params = {}) {
  // Get user's device info from database
  const user = await db.users.findOne({ id: userId });
  const devices = await db.devices.find({ userId: userId });

  for (const device of devices) {
    const title = getTranslation(device.locale, `${notificationKey}_title`, params);
    const body = getTranslation(device.locale, `${notificationKey}_body`, params);

    await admin.messaging().send({
      token: device.fcmToken,
      notification: {
        title,
        body,
      },
      data: {
        notification_key: notificationKey,
        ...params,
      },
    });
  }
}

// Usage
sendLocalizedNotification('user123', 'order_shipped', { orderId: '12345' });

Handling Incoming Push Notifications

// lib/services/fcm_handler.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

class FCMHandler {
  static final FlutterLocalNotificationsPlugin _localNotifications =
      FlutterLocalNotificationsPlugin();

  static Future<void> initialize() async {
    // Handle foreground messages
    FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // Handle background messages
    FirebaseMessaging.onBackgroundMessage(_handleBackgroundMessage);

    // Handle notification tap
    FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
  }

  static Future<void> _handleForegroundMessage(RemoteMessage message) async {
    // When app is in foreground, show local notification
    // The message from server is already localized
    final notification = message.notification;
    if (notification == null) return;

    await _localNotifications.show(
      message.hashCode,
      notification.title,
      notification.body,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'push_channel',
          'Push Notifications',
          importance: Importance.high,
        ),
      ),
      payload: message.data.toString(),
    );
  }

  @pragma('vm:entry-point')
  static Future<void> _handleBackgroundMessage(RemoteMessage message) async {
    // Background messages are shown automatically by the system
    // Just handle any data processing here
    print('Background message: ${message.messageId}');
  }

  static void _handleNotificationTap(RemoteMessage message) {
    // Navigate based on notification type
    final data = message.data;
    final type = data['notification_key'];

    switch (type) {
      case 'order_shipped':
        // Navigate to order details
        break;
      case 'sale':
        // Navigate to sale page
        break;
    }
  }
}

Part 3: Advanced Patterns

Notification Channels per Language (Android)

Android notification channels can be localized:

// lib/services/notification_channels.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class NotificationChannels {
  static Future<void> createLocalizedChannels(AppLocalizations l10n) async {
    final plugin = FlutterLocalNotificationsPlugin();
    final android = plugin.resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>();

    if (android == null) return;

    // Create localized channels
    await android.createNotificationChannel(
      AndroidNotificationChannel(
        'orders',
        l10n.channelOrdersName,
        description: l10n.channelOrdersDescription,
        importance: Importance.high,
      ),
    );

    await android.createNotificationChannel(
      AndroidNotificationChannel(
        'promotions',
        l10n.channelPromotionsName,
        description: l10n.channelPromotionsDescription,
        importance: Importance.defaultImportance,
      ),
    );

    await android.createNotificationChannel(
      AndroidNotificationChannel(
        'reminders',
        l10n.channelRemindersName,
        description: l10n.channelRemindersDescription,
        importance: Importance.low,
      ),
    );
  }

  /// Update channel names when locale changes
  static Future<void> updateChannelsForLocale(AppLocalizations l10n) async {
    // Note: On Android, channel names update when you recreate them
    // with the same ID but different name
    await createLocalizedChannels(l10n);
  }
}

Rich Notifications with Localized Actions

// lib/services/rich_notifications.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class RichNotifications {
  static Future<void> showOrderNotification({
    required AppLocalizations l10n,
    required String orderId,
    required String imageUrl,
  }) async {
    final plugin = FlutterLocalNotificationsPlugin();

    // Download image for big picture style
    final response = await http.get(Uri.parse(imageUrl));
    final bigPicture = ByteArrayAndroidBitmap(response.bodyBytes);

    final androidDetails = AndroidNotificationDetails(
      'orders',
      l10n.channelOrdersName,
      styleInformation: BigPictureStyleInformation(
        bigPicture,
        contentTitle: l10n.notificationOrderTitle,
        summaryText: l10n.notificationOrderBody(orderId),
      ),
      actions: [
        AndroidNotificationAction(
          'track',
          l10n.actionTrackOrder,
          showsUserInterface: true,
        ),
        AndroidNotificationAction(
          'details',
          l10n.actionViewDetails,
          showsUserInterface: true,
        ),
      ],
    );

    final iosDetails = DarwinNotificationDetails(
      categoryIdentifier: 'order',
      attachments: [
        DarwinNotificationAttachment(imageUrl),
      ],
    );

    await plugin.show(
      orderId.hashCode,
      l10n.notificationOrderTitle,
      l10n.notificationOrderBody(orderId),
      NotificationDetails(android: androidDetails, iOS: iosDetails),
    );
  }
}

Topic-Based Multilingual Notifications

Use FCM topics to segment users by language:

// lib/services/fcm_topics.dart
import 'package:firebase_messaging/firebase_messaging.dart';

class FCMTopics {
  static final _messaging = FirebaseMessaging.instance;

  /// Subscribe to language-specific topics
  static Future<void> subscribeToLocaleTopics(String locale) async {
    // Unsubscribe from all language topics first
    await _unsubscribeFromAllLocaleTopics();

    // Subscribe to new locale topic
    await _messaging.subscribeToTopic('news_$locale');
    await _messaging.subscribeToTopic('promotions_$locale');
    await _messaging.subscribeToTopic('updates_$locale');
  }

  static Future<void> _unsubscribeFromAllLocaleTopics() async {
    final locales = ['en', 'es', 'de', 'fr', 'ja', 'zh'];

    for (final locale in locales) {
      await _messaging.unsubscribeFromTopic('news_$locale');
      await _messaging.unsubscribeFromTopic('promotions_$locale');
      await _messaging.unsubscribeFromTopic('updates_$locale');
    }
  }
}

// When user changes language
void onLocaleChanged(String newLocale) {
  FCMTopics.subscribeToLocaleTopics(newLocale);
  FCMService.updateLocale(newLocale);
}

Part 4: Testing Notification Localization

Unit Testing Notification Content

// test/notification_content_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockAppLocalizations extends Mock implements AppLocalizations {
  @override
  String get notificationReminderTitle => 'Don\'t forget!';

  @override
  String notificationReminderBody(int count) =>
      'You have $count items waiting in your cart';
}

void main() {
  group('NotificationContent', () {
    late MockAppLocalizations mockL10n;

    setUp(() {
      mockL10n = MockAppLocalizations();
    });

    test('should return localized title', () {
      final content = NotificationContent(
        titleKey: 'notification_reminder_title',
        bodyKey: 'notification_reminder_body',
      );

      expect(content.getTitle(mockL10n), 'Don\'t forget!');
    });

    test('should interpolate body arguments', () {
      final content = NotificationContent(
        titleKey: 'notification_reminder_title',
        bodyKey: 'notification_reminder_body',
        bodyArgs: {'count': '5'},
      );

      // This would need the actual l10n implementation
      // to test interpolation properly
    });
  });
}

Integration Testing with Multiple Locales

// integration_test/notification_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Notification Localization', () {
    testWidgets('shows English notification', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('en'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const NotificationTestPage(),
        ),
      );

      await tester.tap(find.byKey(const Key('show_notification')));
      await tester.pumpAndSettle();

      // Verify notification content
      // (actual verification depends on your testing setup)
    });

    testWidgets('shows Spanish notification', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: const Locale('es'),
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: const NotificationTestPage(),
        ),
      );

      await tester.tap(find.byKey(const Key('show_notification')));
      await tester.pumpAndSettle();

      // Verify Spanish notification content
    });
  });
}

Best Practices

1. Keep Notification Text Short

Notifications have limited space. Keep translations concise:

// Good
"notificationSaleTitle": "50% Off Today!"

// Too long - will be truncated
"notificationSaleTitle": "Amazing Flash Sale with 50% Discount on All Items Available Today Only!"

2. Test on Actual Devices

Notification rendering varies between devices. Test on multiple:

  • Different Android versions (notification channels, styles)
  • iOS versions (action buttons, rich notifications)
  • Different screen sizes

3. Handle Missing Locales

Always provide fallback translations:

String getLocalizedNotification(String key, String locale) {
  return translations[locale]?[key] ??
         translations['en']?[key] ??
         key;
}

4. Consider Time Zones

Schedule notifications based on user's local time:

Future<void> scheduleLocalizedReminder({
  required BuildContext context,
  required int hourOfDay,
}) async {
  // Schedule for user's local 9 AM, not server's
  final now = DateTime.now();
  var scheduledDate = DateTime(now.year, now.month, now.day, hourOfDay);

  if (scheduledDate.isBefore(now)) {
    scheduledDate = scheduledDate.add(const Duration(days: 1));
  }

  await LocalNotificationService.scheduleLocalized(
    context: context,
    id: 1,
    content: morningReminderContent,
    scheduledDate: scheduledDate,
  );
}

5. Respect User Preferences

Let users choose notification language:

class NotificationPreferences {
  static Future<String> getNotificationLocale() async {
    final prefs = await SharedPreferences.getInstance();

    // Priority: notification preference > app locale > system locale
    return prefs.getString('notification_locale') ??
           prefs.getString('app_locale') ??
           PlatformDispatcher.instance.locale.languageCode;
  }
}

Summary

Effective notification localization requires:

  1. Local notifications: Use Flutter's localization system to generate content
  2. Push notifications: Send user locale to server, localize server-side
  3. Notification channels: Localize channel names and descriptions
  4. Topic subscriptions: Segment users by language for broadcasts
  5. Testing: Verify notifications in all supported locales

With these patterns, your notifications will engage users in their native language, driving higher open rates and better retention.

Related Resources