WordPress JSON Translation: Translating Block Editor JavaScript

You translated your plugin. The PHP strings show up perfectly in the admin settings, the frontend templates, the email notifications - all localized. Then you open the block editor, and every label in your custom Gutenberg block is stubbornly, mockingly English. The "Add Item" button, the inspector panel headings, the placeholder text. Your .mo file is loaded. So why are these strings not translating?
Because since WordPress 5.0 and the arrival of Gutenberg, JavaScript strings do not come from your .mo file at all. They need a completely separate, per-script WordPress JSON translation file - and if you do not generate it, your block editor stays in English no matter how complete your .po file is. This is one of the most common and confusing localization gaps in modern WordPress development. This guide explains exactly why it happens, how the JSON translation system works, the MD5-hashed filenames that trip everyone up, and the full toolchain to fix it.
Why Your Block Editor Strings Stay English
Short answer: PHP and JavaScript use two entirely different translation delivery systems in WordPress, and your .mo file only feeds the PHP one.
Two Translation Systems, One Plugin
When WordPress runs load_plugin_textdomain(), it reads your compiled .mo file into PHP memory. Every __(), _e(), and _x() call in your PHP code looks up its translation there. This works because PHP renders server-side - the .mo data is right there in the same process.
JavaScript is different. Your block code runs in the browser, long after PHP has finished. It cannot reach into a server-side .mo file. Instead, the @wordpress/i18n package - the JS equivalent of Gettext, exposing __(), _x(), and sprintf() to your scripts - expects translations to be delivered as a JSON payload attached to the specific script that needs them.
What Happens to an Untranslated JS String
So a block with strings like this:
import { __ } from '@wordpress/i18n';
registerBlockType( 'myplugin/feature-box', {
title: __( 'Feature Box', 'myplugin' ),
edit: () => {
return <Button>{ __( 'Add Item', 'myplugin' ) }</Button>;
},
} );
will never find "Feature Box" or "Add Item" in your .mo file, because the browser never reads .mo files. Those strings need to arrive as JSON, wired to this exact script handle. If you have not set that up, the JS __() calls simply return the original English - silently, with no error in the console.
Wiring JSON Translations with wp_set_script_translations()
The bridge between your script and its JSON translations is a single PHP function: wp_set_script_translations(). The answer to "how does WordPress know which JSON file belongs to which script" is: you tell it, by registering the script and then declaring its text domain and the folder where the JSON lives.
Registering the Script and Its Translation Folder
add_action( 'init', function () {
wp_register_script(
'myplugin-editor',
plugins_url( 'build/index.js', __FILE__ ),
array( 'wp-blocks', 'wp-i18n', 'wp-element' ),
'1.0.0'
);
// Tell WordPress where this script's JSON translations live
wp_set_script_translations(
'myplugin-editor', // the registered script handle
'myplugin', // text domain
plugin_dir_path( __FILE__ ) . 'languages'
);
} );
When the editor loads myplugin-editor, WordPress now knows to look in your languages/ folder for a JSON file matching this script and the current user's locale. If it finds one, it injects the translations before your script runs, and the JS __() calls resolve correctly. The handle you pass must exactly match a registered script - a mismatched or missing handle is the second most common reason translations silently fail.
The MD5-Hashed Filename Nobody Expects
Here is the detail that derails almost everyone. The JSON file WordPress looks for is not named something tidy like myplugin-fr_FR.json. It is named with an MD5 hash of the script's source path:
Decoding the Filename Pattern
myplugin-fr_FR-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.json
The pattern is {textdomain}-{locale}-{md5}.json, where the hash is the MD5 of the relative path to the script file (for example build/index.js) as registered. WordPress computes this hash at runtime to find the right JSON for the right script. If you hand-name your file, WordPress will not find it, and you will swear the system is broken when it is just looking for a different filename.
You do not compute the hash yourself. The WP-CLI i18n command does it for you, which is exactly why you must use the tooling rather than crafting these files by hand. Understanding that the hash exists, though, is what saves you hours when a JSON file is present in languages/ but still ignored - it is almost always a filename hash mismatch because the script path changed.
The Full Workflow: make-pot to make-json
The good news is that you keep your translations in the same .po files you already use. The JSON is a derived artifact generated at the end. The answer to "do I maintain JS strings separately" is no - they live in the same .pot/.po as your PHP strings, and one extra command splits out the JS ones into JSON.
The Four-Command Pipeline
Here is the complete pipeline:
# 1. Extract ALL translatable strings (PHP and JS) into one template
wp i18n make-pot . languages/myplugin.pot
# 2. Translate languages/myplugin-fr_FR.po as usual (Poedit, AI, etc.)
# 3. Compile the .mo for PHP strings (server-side, as always)
wp i18n make-mo languages/
# 4. Generate the MD5-hashed JSON files for JS strings
wp i18n make-json languages/ --no-purge
Step 1's make-pot is smart enough to scan both your .php files and your .js/.jsx source, so a single .po per locale holds everything. Step 4's make-json reads each translated .po, finds the entries that came from JavaScript files, and writes out one correctly-hashed JSON per script. The --no-purge flag keeps the JS strings in your .po too, so a later make-mo does not lose them - without it, make-json strips JS entries out of the .po, which surprises people who run the commands in the wrong order.
A generated JSON file looks like a Jed-format translation set:
{
"translation-revision-date": "2026-06-12 10:00+0000",
"generator": "WP-CLI/2.x",
"domain": "messages",
"locale_data": {
"messages": {
"": { "domain": "messages", "lang": "fr_FR" },
"Feature Box": [ "Bloc fonctionnalité" ],
"Add Item": [ "Ajouter un élément" ]
}
}
}
WordPress reads locale_data and feeds it to @wordpress/i18n before your script runs. Now __( 'Add Item', 'myplugin' ) in the browser returns Ajouter un élément, and your block editor is finally localized.
How This Differs From i18next JSON
Both systems use JSON, both target JavaScript, and that surface similarity causes real confusion. They are not interchangeable. WordPress block-editor JSON is a Gettext-derived, MD5-hashed, per-script payload consumed by @wordpress/i18n. i18next JSON is a flat or nested key-value file consumed by react-i18next or next-intl, with its own {{interpolation}} syntax and plural key conventions.
If you are working in plain React or Next.js outside WordPress, you want the i18next approach, which we cover in translating i18next JSON in React and Next.js. Inside WordPress, you want the make-json workflow above. Mixing them up - for instance, hand-writing flat i18next-style JSON and expecting wp_set_script_translations() to load it - simply will not work, because WordPress is looking for the hashed Jed format, not arbitrary key-value pairs.
One Source, Every Format You Need
The fragility in all of this is the translation step in the middle. Your .po file feeds both the .mo (PHP) and the JSON (JavaScript), so a single botched translation - a mangled %s, a broken <strong> tag, a renamed plural form - poisons both outputs at once. And because JS strings are loaded asynchronously in the browser, a structural error there often surfaces as a blank label or a hard crash rather than a graceful fallback.
One Upload, PHP and JavaScript Covered
This is where a translation pipeline that understands Gettext structure earns its place. SimplePoTranslate takes one source .po or .pot and produces clean, translated output in multiple formats from a single upload - .po, .mo, .json, .php, and .xliff - so you are not stitching together separate tools for your PHP and JavaScript layers. Its Syntax Locking holds %s, %1$s, {count}, and inline HTML in place, which matters doubly for block-editor strings where a broken placeholder can take down the whole editor panel. We go deeper on the one-source, many-outputs model in one file in, five formats out.
You still run make-json to produce the hashed files WordPress expects - that step is WordPress-specific and stays in your build. But the translation itself, the part most likely to break your JS strings, is handled by a context-aware engine instead of a find-and-replace script.
Conclusion
The reason your block editor stays English is structural, not a bug: WordPress JSON translation is a separate delivery system from the .mo file, built specifically because browsers cannot read server-side Gettext data. Once you understand that JavaScript strings need per-script, MD5-hashed JSON generated by wp i18n make-json and wired up with wp_set_script_translations(), the fix is mechanical. Keep your strings in one .po, compile the .mo for PHP, and run make-json for JS.
Get the translation step right and both outputs follow. Get it wrong and you debug blank editor panels for an afternoon.
Ready to translate your WordPress JSON and PHP strings from one clean source? Try SimplePoTranslate free — no credit card required. The free tier translates real
.poand.potfiles with placeholder-safe Syntax Locking, so your block editor strings ship correct.