Why Localization Matters for FiveM Servers
FiveM roleplay communities span the globe, with massive player bases in Germany, France, Brazil, Turkey, and dozens of other countries. If your scripts only support English, you are cutting off a huge portion of potential customers and limiting the communities that can use your resources. A properly localized script adapts all user-facing text, notifications, menus, and error messages to the player's preferred language. This is not just a quality-of-life feature but a competitive advantage that separates amateur scripts from professional ones. The good news is that implementing internationalization (i18n) in FiveM is straightforward once you understand the pattern.
Setting Up the Locale System
The foundation of any localization system is a structured way to store and retrieve translated strings. The most common approach in FiveM is using locale files, one per language, stored in a locales directory inside your resource. Each file exports a table of key-value pairs where the key is a unique identifier and the value is the translated string. Here is how to structure your locale module:
-- locales/en.lua
Locales = Locales or {}
Locales['en'] = {
['job_started'] = 'You have started your shift as %s.',
['job_ended'] = 'You have ended your shift. Earnings: $%d',
['not_enough_money'] = 'You do not have enough money. You need $%d.',
['inventory_full'] = 'Your inventory is full. Free up some space first.',
['vehicle_spawned'] = 'Your vehicle has been spawned nearby.',
['access_denied'] = 'You do not have permission to do that.',
['cooldown_active'] = 'Please wait %d seconds before doing that again.',
['item_received'] = 'You received %dx %s.',
}
-- locales/de.lua
Locales = Locales or {}
Locales['de'] = {
['job_started'] = 'Du hast deine Schicht als %s begonnen.',
['job_ended'] = 'Du hast deine Schicht beendet. Verdienst: $%d',
['not_enough_money'] = 'Du hast nicht genug Geld. Du brauchst $%d.',
['inventory_full'] = 'Dein Inventar ist voll. Schaffe zuerst Platz.',
['vehicle_spawned'] = 'Dein Fahrzeug wurde in der Naehe gespawnt.',
['access_denied'] = 'Du hast keine Berechtigung dafuer.',
['cooldown_active'] = 'Bitte warte %d Sekunden, bevor du das erneut tust.',
['item_received'] = 'Du hast %dx %s erhalten.',
}
Building the Translation Function
The core of the system is a translation function that looks up keys and supports string formatting with variable arguments. This function should fall back gracefully to a default language when a key is missing in the active locale, and it should warn developers about missing translations during development rather than showing raw keys to players.
-- shared/locale.lua
local currentLocale = 'en'
local fallbackLocale = 'en'
function SetLocale(locale)
if Locales[locale] then
currentLocale = locale
else
print(('[^1LOCALE^0] Language "%s" not found, falling back to "%s"'):format(locale, fallbackLocale))
currentLocale = fallbackLocale
end
end
function L(key, ...)
local str = nil
if Locales[currentLocale] and Locales[currentLocale][key] then
str = Locales[currentLocale][key]
elseif Locales[fallbackLocale] and Locales[fallbackLocale][key] then
print(('[^3LOCALE^0] Missing key "%s" for locale "%s", using fallback'):format(key, currentLocale))
str = Locales[fallbackLocale][key]
end
if not str then
print(('[^1LOCALE^0] Missing translation key: "%s"'):format(key))
return key
end
if ... then
return str:format(...)
end
return str
end
Using Translations in Your Scripts
Once the locale module is loaded, using translations throughout your script is as simple as calling the L() function with the key and any format arguments. This keeps your script code clean and separates content from logic entirely.
-- server/main.lua
RegisterNetEvent('myresource:startJob', function(jobName)
local src = source
local xPlayer = ESX.GetPlayerFromId(src) -- or your framework equivalent
if not xPlayer then return end
if not HasPermission(src, jobName) then
TriggerClientEvent('ox_lib:notify', src, {
title = L('access_denied'),
type = 'error'
})
return
end
ActiveJobs[src] = { name = jobName, started = os.time() }
TriggerClientEvent('ox_lib:notify', src, {
title = L('job_started', jobName),
type = 'success'
})
end)
Per-Player Language Detection
A truly professional localization system detects each player's language automatically. You can accomplish this by reading the client's game language setting or by letting players choose their language through a config or command. The client-side detection approach uses the GetCurrentLanguage native, which returns the game language as a two-letter code. You can then send this to the server so that all notifications for that player use their preferred language.
-- client/locale_detect.lua
CreateThread(function()
local gameLang = GetCurrentLanguage()
-- Map GTA language codes to your locale codes
local langMap = {
['en-us'] = 'en',
['de-de'] = 'de',
['fr-fr'] = 'fr',
['es-es'] = 'es',
['pt-br'] = 'pt',
['it-it'] = 'it',
['pl-pl'] = 'pl',
['tr-tr'] = 'tr',
['ru-ru'] = 'ru',
['zh-cn'] = 'zh',
['ja-jp'] = 'ja',
['ko-kr'] = 'ko',
}
local detected = langMap[gameLang] or 'en'
SetLocale(detected)
TriggerServerEvent('myresource:setPlayerLocale', detected)
end)
Server-Side Per-Player Locale Storage
On the server side, store each player's locale preference so you can format messages in the correct language before sending them. This is critical for server-triggered notifications and chat messages that originate from server-side logic where you do not have direct access to the client's locale setting.
-- server/locale_manager.lua
local PlayerLocales = {}
RegisterNetEvent('myresource:setPlayerLocale', function(locale)
local src = source
if Locales[locale] then
PlayerLocales[src] = locale
else
PlayerLocales[src] = 'en'
end
end)
AddEventHandler('playerDropped', function()
PlayerLocales[source] = nil
end)
function GetPlayerLocale(src)
return PlayerLocales[src] or 'en'
end
function LForPlayer(src, key, ...)
local locale = GetPlayerLocale(src)
local str = nil
if Locales[locale] and Locales[locale][key] then
str = Locales[locale][key]
elseif Locales['en'] and Locales['en'][key] then
str = Locales['en'][key]
end
if not str then return key end
if ... then return str:format(...) end
return str
end
Localizing NUI and JavaScript Interfaces
Many FiveM scripts use NUI (HTML/JS) for their user interfaces, and these need localization too. The best approach is to send the entire locale table to the NUI frame when it initializes, then use a JavaScript translation function that mirrors the Lua one. This avoids constant back-and-forth NUI callbacks for every string.
// nui/js/locale.js
let currentLocale = {};
let fallbackLocale = {};
window.addEventListener('message', (event) => {
if (event.data.action === 'setLocale') {
currentLocale = event.data.locale || {};
fallbackLocale = event.data.fallback || {};
updateAllTranslations();
}
});
function L(key, ...args) {
let str = currentLocale[key] || fallbackLocale[key] || key;
if (args.length > 0) {
let i = 0;
str = str.replace(/%[sd]/g, () => args[i++] ?? '');
}
return str;
}
function updateAllTranslations() {
document.querySelectorAll('[data-locale]').forEach((el) => {
const key = el.getAttribute('data-locale');
el.textContent = L(key);
});
}
Resource Manifest Configuration
Your fxmanifest.lua needs to include all locale files so they are loaded when the resource starts. Use a glob pattern to automatically pick up any new locale files you add without having to update the manifest each time. Make sure the shared locale module loads before the locale data files.
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
shared_scripts {
'shared/locale.lua',
'locales/*.lua',
}
client_scripts {
'client/locale_detect.lua',
'client/main.lua',
}
server_scripts {
'server/locale_manager.lua',
'server/main.lua',
}
ui_page 'nui/index.html'
files {
'nui/**/*',
}
Best Practices for FiveM Localization
- Use descriptive keys instead of numeric IDs. Keys like
inventory_fullare self-documenting and make maintenance easier thanmsg_042. - Always use format placeholders (
%s,%d) for dynamic values instead of string concatenation. Different languages have different word orders, so the values need to be insertable at different positions. - Include context comments in your locale files so translators understand where each string is displayed and what the format arguments represent.
- Test with long strings. German text is typically 30% longer than English. Make sure your UI elements can handle longer translations without breaking layout.
- Never hardcode user-facing strings. Every notification, menu label, help text, and error message should go through the
L()function, even if you only support one language initially. - Provide a language command like
/lang deso players can override the auto-detected language at any time.