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:
- Local notifications: Use Flutter's localization system to generate content
- Push notifications: Send user locale to server, localize server-side
- Notification channels: Localize channel names and descriptions
- Topic subscriptions: Segment users by language for broadcasts
- 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.