Content Manager i18n Preview#
Preview in the Strapi Content Manager lets editors open a live preview of their content in a front-end application. When the content type has i18n enabled, the correct locale must be propagated through the full request chain: from the admin UI query string, through the preview controller's validation layer, into the Document Service's locale filtering pipeline. A gap at any step produces a wrong-locale preview URL or an unhandled error.
Entry points:
| Layer | File |
|---|---|
| Route | preview/routes/preview.ts — GET /preview/url/:contentType |
| Controller | preview/controllers/preview.ts |
| Validation | preview/controllers/validation/preview.ts |
| Frontend page | admin/src/preview/pages/Preview.tsx |
| Side panel | admin/src/preview/components/PreviewSidePanel.tsx |
Request Chain Overview#
Admin UI (Preview.tsx / PreviewSidePanel.tsx)
→ GET /content-manager/preview/url/:contentType?documentId=&locale=&status=
→ validatePreviewUrl() [validation/preview.ts]
→ previewService.getPreviewUrl(uid, params)
→ user-defined handler(uid, { documentId, locale, status })
Frontend locale source: Preview.tsx reads plugins.i18n.locale from the URL query string via useQueryParams and passes it as the locale query parameter . PreviewSidePanel.tsx reads document.locale from the already-loaded document object and passes it directly . Both components pre-warm the RTK Query cache so the /preview route loads the URL instantly.
Route: The endpoint is an admin-only GET at /preview/url/:contentType, authenticated via the admin::isAuthenticatedAdmin policy. No i18n middleware (validateLocaleCreation) is registered on this route; locale existence is not validated against the database at this layer .
Validation Layer: validatePreviewUrl#
validatePreviewUrl in validation/preview.ts is responsible for sanitising request params before they reach the user-defined handler. Key behaviours:
- Schema:
documentId(optional),locale(nullable, optional),status(optional) — validated with Zod .localeis accepted asnull, meaning an explicitlocale=nullpasses through. - Content type guard: Throws
ValidationError('Invalid content type')if theuidresolves to a non-content-type model . - Collection type guard: Throws
ValidationError('documentId is required for Collection Types')whendocumentIdis absent for a collection type . - Single-type
documentIdresolution: For single types,documentIdis optional in the request but required by the handler interface. The validation layer resolves it by callingstrapi.documents(uid).findFirst()— with no locale filter — and setsnewParams.documentIdfrom the result . If no document exists, it throwsNotFoundError('Document not found'). - Default
status: Ifstatusis absent, it defaults to'draft'for Draft & Publish-enabled types and'published'otherwise .
i18n gap:
findFirst()called here is unfiltered by locale. For single types, it returns the first locale entry found in the database, which may not match thelocaleparam the caller passed. Thelocalevalue flows to the handler unchanged but thedocumentIdwas resolved without locale scope. This is benign when a single-type document'sdocumentIdis shared across all locales (the handler should uselocaleto select the right translation), but could mislead if the handler relies ondocumentIdalone.
Document Service Locale Filtering#
Within the Document Service's repository.ts, every read operation pipes through a fixed locale transform sequence :
i18n.defaultLocale(contentType)— if the content type is localized and nolocalewas provided, fetches the system default locale from the i18n plugin and inserts it intoparams.locale.i18n.localeToLookup(contentType)/i18n.multiLocaleToLookup(contentType)— translatesparams.localeintoparams.lookup.locale, which is later applied as aWHEREfilter at the database level .
findFirst (used by single-type documentId resolution in validation) applies localeToLookup, meaning a locale already present in params will be scoped . However, since validatePreviewUrl calls findFirst() with no locale argument, the defaultLocale transform will always fall back to the system default locale, not the requested one.
Locale format validation (checkLocale in repository.ts) applies BCP 47 format checks only when config.api.documents.strictParams is true (the default is off) . No check against the configured-locales table is performed here. That existence check lives exclusively in the i18n plugin's validateLocaleCreation middleware, which is registered only on standard Content Manager collection/single-type routes — not on the preview route .
Known Issues and Edge Cases#
Locale-restricted roles + no locale in URL (issue #27247): When a user's role restricts locale access and they land on a localized single/collection type with no locale in the query string, the default-locale resolution path triggers a 403 Forbidden with a generic error screen instead of the locale-permission messaging shown when switching locale explicitly . This is a gap between the explicit-locale-switch path (which has graceful handling) and the implicit default-locale path.
Preview route skips i18n locale-existence middleware: Unlike standard Content Manager routes, the preview URL endpoint (GET /preview/url/:contentType) does not have validateLocaleCreation middleware applied. An invalid (but syntactically plausible) locale string will reach the user-defined handler without a database existence check . This is consistent with the Content API behavior but inconsistent with the standard CM edit-view behavior.
Single-type documentId resolved without locale: The findFirst() call in validatePreviewUrl uses the system default locale (via defaultLocale transform), not the locale the caller requested. For i18n single types, the documentId is locale-independent (all locales of a single-type document share the same documentId), so this is functionally correct in Strapi's data model — but the handler must use the passed locale param to select the right translation, not make assumptions based on documentId alone .
Key Files Reference#
| File | Role |
|---|---|
packages/core/content-manager/server/src/preview/routes/preview.ts | Route definition; GET /preview/url/:contentType with isAuthenticatedAdmin policy |
packages/core/content-manager/server/src/preview/controllers/preview.ts | Controller; calls validatePreviewUrl then previewService.getPreviewUrl |
packages/core/content-manager/server/src/preview/controllers/validation/preview.ts | Request param validation; single-type documentId resolution; default status logic |
packages/core/core/src/services/document-service/repository.ts | findFirst / findMany — locale transform pipeline; checkLocale BCP 47 validation |
packages/core/core/src/services/document-service/internationalization.ts | defaultLocale, localeToLookup, multiLocaleToLookup, localeToData transforms |
packages/core/content-manager/admin/src/preview/pages/Preview.tsx | Preview page; reads locale from URL query (plugins.i18n.locale) |
packages/core/content-manager/admin/src/preview/components/PreviewSidePanel.tsx | Side panel; reads locale from document.locale; pre-warms RTK Query cache |