diff --git a/docs/backend/ARCHITECTURE.md b/docs/backend/ARCHITECTURE.md
index baffb0e9..a863094f 100644
--- a/docs/backend/ARCHITECTURE.md
+++ b/docs/backend/ARCHITECTURE.md
@@ -403,6 +403,24 @@ Rules:
browser on its own.
- Unauthenticated requests get a JSON 401 (not a redirect to `/login`), so `fetch` callers can handle them.
+### Caching the metadata endpoints
+
+View descriptors, entity metadata and navigation (`GET /apps/api/metadata/views/{id}`, `/entities`, `/entities/{id}`,
+`/entities/{id}/views[/{view}]`, `/entities/by-path`, `/navigation`) are practically static, but not always: texts depend
+on the request locale (`Messages.getDefaultLocale()`, which an application resolves per request through its
+`LocaleProvider`), and an application may build descriptors dynamically or vary them per user. The framework therefore
+adds nothing by default and leaves the decision to the application through `MetadataHttpCachePolicy`:
+
+- **Default:** no cache headers, exactly as before.
+- **`dynamia.app.metadata.http-cache=etag`:** the included `ContentEtagMetadataHttpCachePolicy`. A strong ETag from the
+ content and the locale, `Cache-Control: private, no-cache` and `Vary: Accept-Language`. The client revalidates on every
+ use and gets a `304` with no body when nothing changed, and never a stale descriptor after a deployment or a hot-reload.
+- **Your own policy:** register a `MetadataHttpCachePolicy` bean (for example an ETag from the application version, or a
+ `Vary` that includes the user). It replaces the included one.
+
+Only the read-only endpoints above take part; actions, entity data (`/entities/ref/**`) and executions are never cached,
+and neither are `/apps/api/dashboard` or `/apps/api/reports`.
+
---
## Dependency Injection & Spring Integration
diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java
new file mode 100644
index 00000000..309802ec
--- /dev/null
+++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java
@@ -0,0 +1,36 @@
+package tools.dynamia.app.controllers.cache;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Locale;
+
+/**
+ * Included policy, enabled with {@code dynamia.app.metadata.http-cache=etag}: a strong ETag derived from the content
+ * and the locale, {@code Cache-Control: private, no-cache} and {@code Vary: Accept-Language}.
+ *
+ * {@code no-cache} makes the client revalidate on every use, which costs a {@code 304} with no body instead of the
+ * whole descriptor, and never serves a stale one after a deployment or a descriptor hot-reload: the ETag is the hash of
+ * what would be sent, so it changes with it.
+ */
+public class ContentEtagMetadataHttpCachePolicy implements MetadataHttpCachePolicy {
+
+ @Override
+ public MetadataCacheDirectives directives(String path, Locale locale, byte[] content) {
+ MessageDigest digest = sha256();
+ digest.update(locale.toLanguageTag().getBytes(StandardCharsets.UTF_8));
+ digest.update((byte) 0);
+ digest.update(content);
+ String eTag = "\"" + HexFormat.of().formatHex(digest.digest(), 0, 16) + "\"";
+ return new MetadataCacheDirectives(eTag, "private, no-cache", "Accept-Language");
+ }
+
+ private static MessageDigest sha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java
new file mode 100644
index 00000000..072aea6b
--- /dev/null
+++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java
@@ -0,0 +1,12 @@
+package tools.dynamia.app.controllers.cache;
+
+/**
+ * HTTP cache headers for one metadata response.
+ *
+ * @param eTag the validator, quoted ({@code "abc"}); when present a matching {@code If-None-Match} is answered with
+ * {@code 304 Not Modified} and no body. May be {@code null}.
+ * @param cacheControl the {@code Cache-Control} value, or {@code null} to not set it
+ * @param vary the {@code Vary} header names, comma separated, or {@code null} to not set it
+ */
+public record MetadataCacheDirectives(String eTag, String cacheControl, String vary) {
+}
diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java
new file mode 100644
index 00000000..39e6a673
--- /dev/null
+++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java
@@ -0,0 +1,102 @@
+package tools.dynamia.app.controllers.cache;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.env.Environment;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.ServletWebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+import tools.dynamia.app.controllers.ApplicationMetadataController;
+import tools.dynamia.commons.Messages;
+import tools.dynamia.commons.StringPojoParser;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.util.List;
+
+/**
+ * Applies the {@link MetadataHttpCachePolicy} to the read-only metadata endpoints of
+ * {@link ApplicationMetadataController}: view descriptors, entity metadata and navigation. Nothing else is touched
+ * (actions, entity data and executions are never cached).
+ *
+ * The policy is the application's bean if there is one; otherwise {@link ContentEtagMetadataHttpCachePolicy} when
+ * {@code dynamia.app.metadata.http-cache=etag}; otherwise none, and responses are exactly as before.
+ */
+@RestControllerAdvice(assignableTypes = ApplicationMetadataController.class)
+public class MetadataHttpCacheAdvice implements ResponseBodyAdvice