diff --git a/.eslintrc.js b/.eslintrc.js
index 921006b0766a68..d574a5e9814605 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -525,6 +525,12 @@ module.exports = {
],
},
},
+ {
+ files: [ 'packages/block-library/src/*/save.[tj]s?(x)' ],
+ rules: {
+ '@wordpress/no-i18n-in-save': 'error',
+ },
+ },
{
files: [ 'packages/interactivity*/src/**' ],
rules: {
diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index a5d42e207cac2f..50f12c69ac9f16 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -84,6 +84,7 @@ The granular rulesets will not define any environment globals. As such, if they
| [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ |
| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | ✓ |
| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | ✓ |
+| [no-i18n-in-save](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-i18n-in-save.md) | Disallow translation functions in block save methods. | |
| [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ |
| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packages | ✓ |
| [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ |
diff --git a/packages/eslint-plugin/docs/rules/no-i18n-in-save.md b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md
new file mode 100644
index 00000000000000..71d2b2d52ccb28
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/no-i18n-in-save.md
@@ -0,0 +1,101 @@
+# Disallow translation functions in block save methods (no-i18n-in-save)
+
+Translation functions should never be used in block `save` functions or `save.js` files.
+
+## Rule details
+
+This rule aims to prevent the use of i18n translation functions (`__`, `_x`, `_n`, `_nx`) in block save methods.
+
+### Why?
+
+When translation functions are used in save methods:
+
+1. Translation is saved to the database: The translated text is stored at the time of saving, not when the content is displayed
+2. No dynamic updates: If the site language changes, previously saved content will not update
+3. Block validation errors: Switching languages causes validation errors because the saved HTML no longer matches what the save function generates
+4. Content locked to language: Content becomes permanently associated with the language active at save time
+
+### What to do instead
+
+- For static text: Use plain English text that PHP can replace during rendering
+- For dynamic labels: Use block attributes and let PHP handle translation during rendering
+- For user content: Store as attributes and render/translate in PHP
+
+## Examples
+
+### Incorrect
+
+```js
+// ❌ Translation in save function
+function save() {
+ return (
+
+ );
+}
+```
+
+```js
+// ❌ Translation in arrow function save
+const save = () => {
+ return __( 'Hello World' );
+};
+```
+
+```js
+// ❌ Translation in object method
+const settings = {
+ save() {
+ return _x( 'Label', 'context' );
+ },
+};
+```
+
+### Correct
+
+```js
+// ✅ No translation in save, handled by PHP
+function save() {
+ return (
+
+ );
+}
+```
+
+```js
+// ✅ Translation in edit function
+function edit() {
+ return (
+
+ );
+}
+```
+
+```js
+// ✅ Use attributes and let PHP translate
+function save( { attributes } ) {
+ return (
+
+ );
+}
+```
+
+## When not to use
+
+Consider carefully before disabling this rule. In most cases, the better fix is to refactor the block to avoid translation in save altogether. If you have a genuine edge case (e.g. a migration block that must preserve previously saved translated strings), prefer one of these alternatives instead:
+
+1. Using a render callback in PHP to handle translation
+2. Storing translatable content in block attributes
+3. Using static text that PHP replaces during rendering
+
+## Further Reading
+
+- [Block API Reference](https://developer.wordpress.org/block-editor/reference-guides/block-api/)
+- [Internationalization in WordPress](https://developer.wordpress.org/apis/internationalization/)
diff --git a/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js
new file mode 100644
index 00000000000000..33dd38b9d88bb7
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/no-i18n-in-save.js
@@ -0,0 +1,270 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../no-i18n-in-save';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+} );
+
+ruleTester.run( 'no-i18n-in-save', rule, {
+ valid: [
+ {
+ code: `
+function edit() {
+ return __( 'Hello World' );
+}
+ `,
+ },
+ {
+ code: `
+const edit = () => {
+ return __( 'Hello World' );
+};
+ `,
+ },
+ {
+ code: `
+const settings = {
+ edit() {
+ return __( 'Hello World' );
+ },
+};
+ `,
+ },
+ {
+ code: `
+// Translation functions are fine in non-save files
+function render() {
+ return __( 'Hello World' );
+}
+ `,
+ },
+ {
+ code: `
+// Translation in edit function
+export default function Edit() {
+ return