Shipping separate language files
By default an application reads all of its translations from one file, translations.json, the same file the Localization Editor edits. That file is an authoring file: it holds every language, every translation status, the collection rules, and the editing history. Shipping it works, but it ties every language to a build. Separate language files split the shipped data into one small, plain-text file per language. Use them when you want to add or update a language without rebuilding the application, hand a single file to a translator or a customer, or keep the deployed data free of the editor's bookkeeping. Stay with translations.json when the application edits its own translations at run time, because the collector writes the JSON model, not language files.
This guide covers the file format and naming, where the localizer looks for the files, how they are loaded on demand, how problems are reported, and how to combine language files with a language picker that remembers the user's choice.
Why ship language files
A language file holds exactly one language and nothing else: the approved translation of every translatable property, keyed by the property's path. Four things follow from that:
- A new language needs no rebuild. The localizer scans its folder when it starts, so dropping
lang.pt-BRnext to the executable adds Brazilian Portuguese to the language list on the next start, or immediately after a rescan. - Translators work on one file. The format is one line per string, readable in any text editor and easy to diff in version control.
- Only approved text ships. The export writes approved translations only, so a proposed or rejected translation can never reach the application through a language file.
- Startup stays cheap. Only each file's short header is read at startup; the translations of a language are read the first time that language is selected.
Producing the files in the editor
The editor writes the files for you. The Export Runtime Files... button in the toolbar of the Translations tab (VCL and FMX only) writes one file per included language, including the source language, into a folder named runtime language files next to the project's translations.json. The editor's Info tab reports where that folder is, whether the exported files are older than the translation data, and, for macOS, iOS and Android targets, whether the files are in the project's deployment. See Editing translations for the editor's tabs.
The editor writes language files but does not read them back into the project: translations.json stays the source of truth. To bring a translator's work back into the project, exchange XLIFF files on the Exchange tab, then export the runtime files again.
The export does not remove anything. A language you later exclude keeps its old file in the folder until you delete it, and the localizer keeps offering that language as long as the file is there.
File format and naming
Each file is UTF-8 text. A short header of _meta. lines identifies the language, followed by one line per translated property:
_meta.locale = "de";
_meta.language = "German";
_meta.pluralforms = "Form2a";
_meta.exported = "2026-10-23 14:05";
Forms.MainForm.btnSave.Text = "Speichern";
Forms.MainForm.miFile.Caption = "&Datei";
NumberSnippets.GlyphCount.Forms[5] = "%d Glyphen";
Snippets.FileSaved.Text = "Gespeichert";
The rules a hand-edited file must follow:
- Every line is
key = "value";. The key is the property's path in the translation model (Forms,Frames,DataModules,Snippets,NumberSnippets, orResourceStrings, then the element names, then the property). The value is double-quoted and ends with";. Only the first=splits the line, so a value may contain=. - Four escapes exist:
\n(line break),\t(tab),\\and\". Every string fits on one line whatever its content. - Comments and blank lines are ignored. A line starting with
;,#,//or/*is a comment. - The language comes from
_meta.locale, never from the file name. The header also carries_meta.language(the display name, which falls back to the locale),_meta.pluralforms, and optionally_meta.contributors. The file of a source language other than English carries_meta.source = "true"; a set of files without that line has an English source. _resid.lines bind string-table numbers to resourcestring names. They appear only when two resourcestrings share the same source text; keep them as exported. See Resource strings.
The export names each file lang.<locale>, for example lang.de or lang.pt-BR. When the localizer scans a folder it treats a file as a language file when its name starts with lang. or its extension is .lang, so de.lang is found as well. The prefix is the global variable LanguageFilePrefix in TMS.TMSFNCLocalizationIO.Lang; change it before the localizer loads if your files use a different prefix.
Where the localizer looks
The localizer scans its folder when it is loaded: in the Loaded step of the TTMSFNCLocalizationLocalizer placed on your data module or form. Which folder it scans depends on how the application was started.
| How the application runs | Folder scanned for language files | Fallback when it holds none |
|---|---|---|
| Started from the RAD Studio IDE on Windows | <project folder>\runtime language files |
The project's own translations.json |
| Started any other way (deployed, from Explorer, on macOS, iOS or Android) | The resolved LocalizationFolder |
translations.json in that same folder |
The IDE case exists so that design time and run time share one copy of the data. Running from the IDE, the application reads the files the editor exported into the project folder, instead of a stale copy in each Win32\Debug or Win64\Release output folder. The localizer recognizes an IDE launch when its parent process is the IDE and the editor has registered the project on this machine, which happens when you open the editor for the project. When either is missing, the application resolves LocalizationFolder as it would after deployment.
When a folder holds both language files and a translations.json, the language files win. A folder is never a mix: either every language comes from language files, or everything comes from the JSON file.
What to deploy
In a deployed application the language files sit directly in the resolved LocalizationFolder, not in a runtime language files subfolder. Copy the contents of <project folder>\runtime language files into that folder. With an empty LocalizationFolder (the {DEPLOY} token), that is the folder the IDE's deployment puts plain files in:
| Platform | Deployment remote path for the lang.* files |
|---|---|
| Windows | Next to the executable |
| macOS | Contents\Resources\StartUp\ |
| iOS | StartUp\ |
| Android | assets\internal\ |
On Windows, copy the files next to the executable with your installer or a post-build step. For the other platforms, add the files in Project > Deployment; the editor's Info tab warns when they are missing or deployed to the wrong remote path.
Loading on demand
A language file is read in two steps:
- At startup, only the header. The scan reads each file's
_meta.lines and stops at the first translation. It creates one entry per language inLanguageFilesand one language definition per file, so the Localization ComboBox can list every language without reading a single translation. - On first selection, the whole file.
TrySetLocalereads the selected language's file the first time that language is activated. The source language is read before any other language, because a translation is only valid against the source text it was made from. A source language without a file needs nothing: the forms already carry its text.
If the file cannot be read, TrySetLocale returns False and the current language stays active. The application never shows the source text under the name of a language it failed to load. Selecting auto (the system language) behaves the same, except that it falls back to the source language instead of failing.
The localizer reports what the scan found. Its LanguageFileMode property tells you whether language files are in use, and its LanguageFiles list describes each file as a TTMSFNCLanguageFile: Tag, Description, FileName, Contributors, PluralForms, the file's Stamp, and whether it is Loaded yet.
procedure TForm1.Button1Click(Sender: TObject);
var
LangFile: TTMSFNCLanguageFile;
I: Integer;
begin
Memo1.Lines.Clear;
if not Localizer.LanguageFileMode then
begin
Memo1.Lines.Add('Translations come from a single translations.json file.');
Exit;
end;
for I := 0 to Localizer.LanguageFiles.Count - 1 do
begin
LangFile := Localizer.LanguageFiles[I];
{ Loaded stays False until the language is selected for the first time. }
Memo1.Lines.Add(Format('%s %s %s loaded: %s',
[LangFile.Tag, LangFile.Description, ExtractFileName(LangFile.FileName),
BoolToStr(LangFile.Loaded, True)]));
end;
{ Files that were skipped, or failed to load, are explained here. }
if Localizer.LanguageFileIssues.Count > 0 then
begin
Memo1.Lines.Add('Issues:');
Memo1.Lines.AddStrings(Localizer.LanguageFileIssues);
end;
end;
void __fastcall TForm1::Button1Click(TObject *Sender)
{
Memo1->Lines->Clear();
if (!Localizer->LanguageFileMode)
{
Memo1->Lines->Add("Translations come from a single translations.json file.");
return;
}
for (int I = 0; I < Localizer->LanguageFiles->Count; I++)
{
TTMSFNCLanguageFile *LangFile = Localizer->LanguageFiles->Items[I];
// Loaded stays false until the language is selected for the first time.
Memo1->Lines->Add(LangFile->Tag + " " + LangFile->Description + " " +
ExtractFileName(LangFile->FileName) + " loaded: " + BoolToStr(LangFile->Loaded, true));
}
// Files that were skipped, or failed to load, are explained here.
if (Localizer->LanguageFileIssues->Count > 0)
{
Memo1->Lines->Add("Issues:");
Memo1->Lines->AddStrings(Localizer->LanguageFileIssues);
}
}
Reporting problems
A broken file never stops the application: it is skipped, and the reason is added to the localizer's LanguageFileIssues list. Each entry starts with the file name. The list is cleared at the start of every scan, and a file that fails to load when its language is selected adds an entry then.
| Message | Cause | What to do |
|---|---|---|
no language information, skipped |
The file has no _meta.locale line, or the line is malformed. |
Re-export the file, or add the header. |
language X is already defined by Y, skipped |
Two files claim the same locale. The first file in name order wins. | Delete one of the files. |
also marked as the source language; X is the source |
Two files carry _meta.source = "true". The first one wins; the second is read as an ordinary language. |
Re-export all files from one project. |
gone; X stays selected until the next start |
The active language's file was deleted and a rescan ran. Its translations stay in memory. | Nothing, or restore the file. |
| Any other message | Reading the file failed when the language was first selected, for example an unreadable file or a header that disagrees with the expected locale. | Check the file; TrySetLocale returned False for it. |
Show this list in a diagnostics view or a log while you test new translations.
Adding languages while the application runs
The localizer's RescanLanguages repeats the startup scan against the current folder. Languages whose file appeared are added to the list, removed files take their language out of the list, and a file whose time stamp changed is read again the next time its language is needed. If the active language's file changed, the application is re-localized right away. Files that did not change keep their translations in memory. A changed source-language file invalidates everything, because every translation depends on the source text.
procedure TForm1.Button2Click(Sender: TObject);
var
Found: Integer;
begin
{ Reads only the header of each file. Unchanged files keep their loaded
translations, changed files are read again the next time they are needed,
and the language list (and any language picker) is refreshed. }
Found := Localizer.RescanLanguages;
Memo1.Lines.Clear;
Memo1.Lines.Add(Format('%d language(s) available', [Found]));
Memo1.Lines.AddStrings(Localizer.LanguageFileIssues);
end;
void __fastcall TForm1::Button2Click(TObject *Sender)
{
// Reads only the header of each file. Unchanged files keep their loaded
// translations, changed files are read again the next time they are needed,
// and the language list (and any language picker) is refreshed.
int Found = Localizer->RescanLanguages();
Memo1->Lines->Clear();
Memo1->Lines->Add(IntToStr(Found) + " language(s) available");
Memo1->Lines->AddStrings(Localizer->LanguageFileIssues);
}
RescanLanguages is also how you apply a LocalizationFolder that you change in code. The localizer scans in its Loaded step, before your form's or data module's OnCreate runs, so a folder assigned in OnCreate takes effect only after a rescan. It works for a folder holding translations.json as well.
Combining language files with a language picker and a remembered choice
PersistLanguageSelection stores the user's choice inside translations.json. A deployment that ships only language files has no such file, so the choice is not remembered between runs. Store it yourself instead. The example below lists the scanned languages in a combo box, switches with TrySetLocale (which loads the file on first use), reports a file that fails to load through LanguageFileIssues, and remembers the choice in an INI file in the user's home folder:
function TForm1.SettingsFileName: string;
begin
Result := TPath.Combine(TPath.GetHomePath, 'MyApp.ini');
end;
procedure TForm1.FormCreate(Sender: TObject);
var
Ini: TIniFile;
Saved: string;
I: Integer;
begin
{ The selection is stored below instead: PersistLanguageSelection writes it
into translations.json, which a language-file deployment does not ship. }
Localizer.PersistLanguageSelection := False;
{ One entry per language file, in the order of LanguageFiles. }
ComboBox1.Items.Clear;
for I := 0 to Localizer.LanguageFiles.Count - 1 do
ComboBox1.Items.Add(Localizer.LanguageFiles[I].Description);
Ini := TIniFile.Create(SettingsFileName);
try
Saved := Ini.ReadString('Localization', 'Language', '');
finally
Ini.Free;
end;
{ TrySetLocale reads the file on first use and fails when it cannot. }
for I := 0 to Localizer.LanguageFiles.Count - 1 do
if SameText(Localizer.LanguageFiles[I].Tag, Saved) and
Localizer.TrySetLocale(Saved) then
ComboBox1.ItemIndex := I;
end;
procedure TForm1.ComboBox1Change(Sender: TObject);
var
LangTag: string;
Ini: TIniFile;
begin
if ComboBox1.ItemIndex < 0 then
Exit;
LangTag := Localizer.LanguageFiles[ComboBox1.ItemIndex].Tag;
if not Localizer.TrySetLocale(LangTag) then
begin
{ The file could not be read; LanguageFileIssues says why. }
ShowMessage(Localizer.LanguageFileIssues.Text);
Exit;
end;
Ini := TIniFile.Create(SettingsFileName);
try
Ini.WriteString('Localization', 'Language', LangTag);
finally
Ini.Free;
end;
end;
System::UnicodeString __fastcall TForm1::SettingsFileName()
{
return System::Ioutils::TPath::Combine(System::Ioutils::TPath::GetHomePath(), "MyApp.ini");
}
void __fastcall TForm1::FormCreate(TObject *Sender)
{
// The selection is stored below instead: PersistLanguageSelection writes it
// into translations.json, which a language-file deployment does not ship.
Localizer->PersistLanguageSelection = false;
// One entry per language file, in the order of LanguageFiles.
ComboBox1->Items->Clear();
for (int I = 0; I < Localizer->LanguageFiles->Count; I++)
ComboBox1->Items->Add(Localizer->LanguageFiles->Items[I]->Description);
System::UnicodeString Saved;
TIniFile *Ini = new TIniFile(SettingsFileName());
try
{
Saved = Ini->ReadString("Localization", "Language", "");
}
__finally
{
delete Ini;
}
// TrySetLocale reads the file on first use and fails when it cannot.
for (int I = 0; I < Localizer->LanguageFiles->Count; I++)
if (SameText(Localizer->LanguageFiles->Items[I]->Tag, Saved) &&
Localizer->TrySetLocale(Saved))
ComboBox1->ItemIndex = I;
}
void __fastcall TForm1::ComboBox1Change(TObject *Sender)
{
if (ComboBox1->ItemIndex < 0)
return;
System::UnicodeString LangTag = Localizer->LanguageFiles->Items[ComboBox1->ItemIndex]->Tag;
if (!Localizer->TrySetLocale(LangTag))
{
// The file could not be read; LanguageFileIssues says why.
ShowMessage(Localizer->LanguageFileIssues->Text);
return;
}
TIniFile *Ini = new TIniFile(SettingsFileName());
try
{
Ini->WriteString("Localization", "Language", LangTag);
}
__finally
{
delete Ini;
}
}
The example turns PersistLanguageSelection off, because the INI file replaces it. The combo box lists only languages that have a file; add an entry for the source language yourself when it ships without one. The ready-made Localization ComboBox lists every defined language, including a source language without a file, and needs no code.
Common pitfalls
- Re-export after every translation session. The language files are a snapshot. Translations approved after the export are not in them until you export again; the editor's Info tab flags files older than the translation data.
- Deploy the files, not the folder. At run time the localizer reads the resolved
LocalizationFolderitself. Aruntime language filessubfolder in the deployment is never scanned. - Language files take precedence over
translations.json. If a deployment folder still contains oldlang.*files, they win over a newer JSON file in the same folder. Remove one of the two. - Only approved translations are exported. A language with only proposed translations produces a file with no translation lines, and the application shows the source text.
- The file name does not decide the language. Renaming
lang.detolang.frstill loads German. Edit_meta.localeto change the language. - Keep one source language. Mixing files exported from projects with different source languages produces the "also marked as the source language" issue and wrong fallbacks.
PersistLanguageSelectionneedstranslations.json. Persist the choice yourself, as in the combination example, when you ship only language files.- Set
LocalizationFolderat design time, or rescan. Changing it inOnCreatedoes nothing untilRescanLanguagesruns. - Language files are desktop and mobile only. TMS WEB Core applications read
translations.jsonfromLocalizationFolder, used as a base URL.