FiveM Script Localization: Locales, Translation Files and Running a Multi-Language City

A German player joins your English city, opens the phone, and every menu reads like assembly instructions from a country they’ve never visited. That’s the moment FiveM script localization stops being optional. Done right, it swaps every prompt, notification and UI label into the player’s language without anyone touching your gameplay logic. Done wrong, half your strings print in English and the other half print the raw key.

Recommended FiveM scripts for your server


This is the hands-on version: where translation files live, how ox_lib, QBCore and ESX each handle them, and the sharp edges — fallbacks, substitution syntax, right-to-left text — that only surface once real players from twelve countries are online at 2am.

What FiveM script localization actually is

FiveM script localization is the practice of pulling every user-facing string out of your Lua and into per-language files, then swapping them at runtime based on a locale setting. Gameplay code references a key such as locale('welcome'); the actual German or French text lives in locales/de.json, never inside the script. One cardinal rule makes the whole thing work: zero human-readable strings hardcoded in logic.

Where translation files live

Nearly every resource follows the same layout — a locales/ folder (sometimes locale/, singular, in QBCore land) holding one file per language, keyed by ISO 639-1 code: en, de, fr, pt, tr. Older resources use Lua tables; modern ones use JSON, which non-coders can edit without blowing up a table over a stray comma.

Two things trip people up before a single string gets translated. Save the files as UTF-8 without a BOM — a byte-order mark silently corrupts the first key and you’ll swear the whole file is broken. And if your NUI fetches the locale JSON directly, declare it in your manifest, or the browser can’t read it:

-- fxmanifest.lua
files {
    'locales/*.json'
}

ox_lib is the exception worth knowing: it reads locale files off disk with LoadResourceFile, so they don’t strictly need a files{} entry. When an ox_lib locale won’t load, the culprit is almost always the convar or a JSON syntax error, not the manifest. If you spend real time in config and locale files, our script config and editing guides cover the surrounding setup.

ox_lib locale: lib.locale() and the locale() global

ox_lib gives you the cleanest modern setup. Drop your JSON in locales/, call lib.locale() once, and every resource that depends on ox_lib gets a global locale() function. For example, locales/en.json:

{
  "welcome": "Welcome to the city, %s!",
  "hello": "Hello",
  "greeting": "${hello}, %s - you have %s new messages"
}
-- anywhere in a resource that requires ox_lib
lib.locale()
print(locale('welcome', playerName))
print(locale('greeting', playerName, count))

Two features earn their keep. Arguments are filled with string.format, so each %s maps to an extra parameter you pass. And ${key} references let one string reuse another at load time, so you write “Hello” exactly once. Set the server language with a replicated convar in server.cfg:

setr ox:locale de

Use setr, not set — the “r” replicates the value to clients, which is what a client-side locale() call reads. Get this wrong and your server console prints German while every client stays stubbornly English.

QBCore and ESX translation tables

Both big frameworks predate ox_lib’s module and each ships its own system. QBCore builds a Lang object from Lua tables and reads it with Lang:t(), using named placeholders:

-- locale/en.lua
local Translations = {
    info = { paycheck = 'You received $%{value}' }
}
Lang = Locale:new({ phrases = Translations, warnOnMissing = true })

-- usage
Lang:t('info.paycheck', { value = 500 })

ESX drives _U() (or TranslateCap) over a Locales['en'] table with %s-style formatting, much like ox_lib. For the deeper QBCore wiring — shared modules, load order, and how Lang:t plugs into notifications — our QBCore framework guides go further than there’s room for here.

Here’s the part nobody lines up side by side:

System Set language File Substitution Missing key
ox_lib setr ox:locale de locales/de.json %s + ${ref} Falls back to English
QBCore setr qb_locale fr locale/fr.lua %{named} Warns, returns the key
ESX esx:locale convar locales/fr.lua %s Errors or prints blank

The substitution column is the single most common source of “why is my text printing %s?” bug reports. Copy a QBCore string into an ox_lib resource and its %{value} won’t expand; copy the other way and %s stays literal. Match the syntax to the system, not to whatever you pasted off a forum.

Fallback keys: what happens when a translation is missing

This is where ox_lib quietly wins. It loads en.json first, then merges the selected language on top, so any key a translator hasn’t reached yet falls back to English instead of showing a blank or the raw key. QBCore and ESX don’t do this — a missing key hands you the key string or an error, live, in front of players.

So before you ship a new language, diff its keys against English. A five-line script that loads both files and prints the keys present in en.json but missing from de.json catches every untranslated string before a player does. Treat en.json as the single source of truth and every other language as a delta from it.

RTL and diacritics: where GTA’s text engine fights back

Translating into French or Portuguese is mechanical. Turkish, Russian, Arabic and Hebrew are where localization earns hazard pay.

The game’s native text functions — AddTextEntry, the BeginTextCommandDisplayText family, 3D floating text — render through GTA’s built-in font atlas. It handles accented Latin and most Cyrillic, but Arabic and Hebrew shaping plus right-to-left ordering simply aren’t there; you get reversed strings or empty boxes. Route those languages through NUI instead, where CEF (the embedded browser) does proper UTF-8, ligatures and direction:

<html lang="ar" dir="rtl">

Pair that with a web font that actually carries the glyphs — the default UI font may not include Arabic. A few more traps worth burning into memory:

  • Never :upper() a localized string. Lua’s string.upper is byte-based ASCII; it mangles multibyte characters and butchers Turkish’s dotted and dotless i. Store the cased form in the locale file instead.
  • Diacritics are fine in JSON as long as the file is UTF-8 — é, ß, ç and uXXXX escapes all work. The BOM is the only thing that’ll bite you.
  • Keep non-Latin text in NUI for menus, HUDs and notifications. Comparing how different scripts render their interfaces — native draw-text versus NUI — is worth doing before you buy; our UI and script comparisons are a decent starting point.

Translate labels, not identifiers

The fastest way to break a server while “translating” it is to translate the wrong strings. Item names, job names, vehicle spawn names and command names are identifiers — your code and the database look them up by exact value. Translate the item label, never its name. Rename the police job to polizei and every job check, boss menu and paycheck routine that references police breaks at once.

The rule of thumb: if a string is shown to a player, it’s a label and it’s fair game. If a string is compared, stored, or used as a table key, leave it in English forever.

A community translation workflow that scales

You don’t have to translate everything yourself, and you shouldn’t. The maintainable pattern:

  1. Use descriptive keys, not English sentences as keys. locale('arrest_confirm') survives a wording change; a key literally named "Are you sure?" orphans every translation the moment you edit it.
  2. Keep en.json canonical and accept other languages as pull requests, or point volunteers at a tool like Crowdin or Weblate so non-coders never touch Lua.
  3. Run the key-diff check on every contribution so a half-finished German file can’t ship with holes.

Loading a dozen language files sounds heavy, but localization is a one-time cost at resource start, not a per-frame one — it won’t move your resmon. If you’re chasing frame budget, that’s a server performance conversation, not a localization one.

Running a genuinely mixed-language city

Here’s the honest limitation most guides skip: ox_lib’s locale convar is server-wide. Every client gets the same language. That’s perfect for a Brazilian community or a French one, and useless when half your players want English and half want Spanish, all at once.

Per-player language needs a client-side layer. The clean architecture: the server sends locale keys, never pre-translated text, and each client renders those keys in the language that player picked (saved as a KVP preference). Server-authoritative notifications stay authoritative; only the final rendering is localized, on the client, per person. For genuinely mixed voice and text comms, an auto chat-translator resource bridges the gap the locale system can’t.

Whatever you run — job scripts, phones, HUDs, admin menus — the requirement never changes: every one of their user-facing strings has to live in a locale file. A script that hardcodes “You are not authorized” can’t be localized at all, and no framework fixes that after the fact.

Localization checklist before you ship

  • No hardcoded user-facing strings anywhere in logic.
  • Locale files saved UTF-8, no BOM; JSON validated.
  • Substitution syntax matches the system (%s versus %{named}).
  • setr convar set, not set; language replicated to clients.
  • Key-diff run: every language matches en.json‘s keys.
  • Non-Latin scripts routed through NUI, never native draw-text.
  • Identifiers left in English; only labels translated.

Get those seven right and a player from Sao Paulo, Istanbul or Berlin joins to a city that speaks their language from the first spawn — which, roughly translated, is how you get them to stay.

FiveM localization FAQ

Do I need ox_lib to localize a script? No. QBCore and ESX have their own locale systems, and you can roll a plain Lua table. ox_lib just hands you English fallback and ${} references for free.

Can each player use a different language? Not with a server-wide convar alone. Per-player language requires a client-side layer that renders locale keys against each player’s saved preference.

Why does my translation print %s or %{value} literally? The substitution syntax doesn’t match the system. ox_lib and ESX use %s; QBCore’s Lang:t uses %{named}.

Upgrade your server — shop our FiveM scripts