Upgrading Strapi 4 to 5 for Headless Frontends
This guide belongs to Strapi Self-Hosted Setup and covers the frontend side of upgrading from Strapi 4 to Strapi 5. The backend upgrade is largely automated by Strapi’s upgrade tool; the changes that break headless frontends are in the API: the flattened response format, documentId as the stable identifier, status replacing publicationState, and changed payloads for webhooks and GraphQL. The guide shows how to upgrade the backend safely, keep the frontend working with a compatibility header, migrate the frontend step by step and remove the header at the end.
Strapi 5 changed the REST response format. In Strapi 4, each entry arrived as { id, attributes: { ... } }, and relations were nested in { data: { id, attributes } } wrappers. In Strapi 5, fields sit directly on the entry, relations are plain objects or arrays, and each entry has a documentId string that identifies the document across its draft, published and localized versions. Every piece of frontend code that reads attributes breaks, which in a typical project is most of the data layer.
The Problem
A media company upgraded its Strapi backend to version 5 on a Friday, following the backend steps of the migration guide, and deployed it. The frontend, which still expected data.attributes, rendered empty pages for every article: no errors, since optional chaining turned every missing field into undefined. Rolling back meant restoring a database that the upgrade had already migrated. The team spent the weekend restoring from backup, and planned the second attempt with the frontend in mind.
How the Upgrade Affects Frontends
Flattened responses. data.attributes.title becomes data.title, and relation wrappers disappear: data.attributes.author.data.attributes.name becomes data.author.name. Media fields are flattened the same way.
documentId. Each document has a string documentId. REST routes such as /api/articles/:id take the documentId in Strapi 5, not the numeric id. Links, caches and stored references that use numeric ids must switch.
status instead of publicationState. Fetching drafts moves from publicationState=preview to status=draft; the default is the published version.
Compatibility header. Sending the header Strapi-Response-Format: v4 makes Strapi 5 return the v4 response format for REST. It exists precisely to decouple the backend upgrade from the frontend migration.
Webhooks and GraphQL. Webhook payloads contain documentId in the entry. The GraphQL API is flattened as well, with pagination metadata moved to _connection queries; a v4 compatibility option in the GraphQL plugin’s configuration eases the transition.
Implementation
Step 1: upgrade the backend on staging. Back up the database and uploads, then run the upgrade tool, which updates dependencies and applies codemods to your backend code.
# In the Strapi project, on a branch, with a fresh database backup
npx @strapi/upgrade major --dry
npx @strapi/upgrade major
npm run build && npm run develop # the database is migrated on first start
Review the codemods’ changes, especially custom controllers, services and lifecycle hooks that used the Entity Service API, and fix what the tool marks for manual work. Start the upgraded backend against a copy of production data on staging.
Step 2: turn on the compatibility header in the frontend. Before production’s backend is upgraded, make every frontend request send the v4 header. The frontend then works with both versions.
// lib/strapi.ts, during the transition
const COMPAT = process.env.STRAPI_V4_COMPAT === "true";
export async function strapiGet<T>(path: string, query = "") {
const res = await fetch(`${process.env.STRAPI_URL}/api/${path}${query}`, {
headers: {
Authorization: `Bearer ${process.env.STRAPI_READ_TOKEN}`,
...(COMPAT ? { "Strapi-Response-Format": "v4" } : {}),
},
});
if (!res.ok) throw new Error(`Strapi ${res.status} for ${path}`);
return (await res.json()) as T;
}
Step 3: migrate the data layer. Move every mapping from API responses to view models into one module, if it is not there already, and switch it to the flat format behind the same flag. Components keep receiving the same view models, so only the mappers change.
// lib/mappers/article.ts
type V4Article = { id: number; attributes: { title: string; slug: string; author?: { data: { attributes: { name: string } } | null } } };
type V5Article = { id: number; documentId: string; title: string; slug: string; author?: { name: string } | null };
export type ArticleView = { key: string; title: string; slug: string; authorName?: string };
export function toArticleView(raw: V4Article | V5Article): ArticleView {
if ("attributes" in raw) {
return { key: String(raw.id), title: raw.attributes.title, slug: raw.attributes.slug, authorName: raw.attributes.author?.data?.attributes.name };
}
return { key: raw.documentId, title: raw.title, slug: raw.slug, authorName: raw.author?.name ?? undefined };
}
Step 4: switch identifiers and parameters. Replace publicationState=preview with status=draft in preview fetches, use documentId in single-entry routes, and update cache tags and webhook handlers to use documentId. Tags built from numeric ids would stop matching after the switch, so change fetch tags and webhook mapping in the same release.
Step 5: remove the header. Once the frontend reads the flat format everywhere, set the flag to false in staging, run the full test suite and a visual comparison, then in production. Delete the v4 branch of the mappers afterwards.
Testing the transition
A visual and data comparison catches what types cannot. Render a sample of pages of every type with the old and the new data path, and compare the rendered text; missing fields show up as differences. Run the same comparison for preview pages and for each locale. Contract tests that fetch each page type with the frontend’s own queries and assert that required fields are present should run in CI against the upgraded staging backend throughout the migration.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Backup | database and uploads before upgrading | Migration cannot be undone in place. |
| Upgrade tool | npx @strapi/upgrade major, dry run first |
Codemods for backend code. |
| Compatibility | Strapi-Response-Format: v4 during transition |
Frontend works with both versions. |
| Mappers | one module, both formats behind a flag | Components unchanged. |
| Identifiers | documentId in routes, tags, webhooks |
Stable across versions and locales. |
| Drafts | status=draft |
publicationState is gone. |
Gotchas & Edge Cases
- Optional chaining hides breakage. Code like
data?.attributes?.titlereturnsundefinedsilently. Add assertions for required fields in mappers, so format mismatches fail loudly. - Numeric ids in URLs. Frontends that used numeric ids in URLs need redirects or a lookup by slug;
documentIdvalues are not numeric. - Plugins. Community plugins may not support Strapi 5 yet. Check each one before starting, and plan replacements.
- Custom routes. Controllers returning hand-built responses are not changed by the header; migrate them explicitly.
- Locales. Localized entries share a
documentIdand differ bylocale. Include the locale in cache keys and tags.
Worked Example
For its second attempt, the media company added the compatibility header to the frontend first and deployed it while production still ran Strapi 4, where the header was harmless. The backend upgrade then went live on a Tuesday without any visible change. Over the next two weeks, the team migrated the 23 mappers to the flat format, switched routes and tags to documentId, and changed preview fetches to status=draft. A comparison of 2,000 rendered pages found 14 differences, all in two mappers, fixed before the header was switched off. The final cutover produced no incidents.
Planning the Backend Side
The frontend is only half of the upgrade. On the backend, list custom code before starting: controllers, services, policies, middlewares, lifecycle hooks and plugins. Code that uses the Entity Service API moves to the Document Service API, which works with documentId and handles drafts and locales explicitly. Database lifecycle hooks still work, but they fire per database row, so a single publish can trigger them for several versions of a document; Document Service middlewares are the recommended place for logic that should run once per document action. Budget time for this review in proportion to the amount of custom code: installations with little customization upgrade in a day, heavily extended ones in weeks.
Rollout Checklist
- Back up the database and uploads, and upgrade on staging first.
- Run the upgrade tool with a dry run and review every codemod change.
- Send the v4 compatibility header from the frontend before production is upgraded.
- Migrate mappers to the flat format behind a flag.
- Switch routes, cache tags and webhook handlers to
documentIdtogether. - Replace
publicationStatewithstatusin preview fetches. - Remove the header after comparing rendered pages.
Frequently Asked Questions
Can we stay on the compatibility header forever?
It is meant for the transition. Plan to remove it, since new features and documentation assume the flat format.
Do we have to upgrade GraphQL clients too?
Yes. The GraphQL schema is flattened as well; use the plugin’s v4 compatibility option during the transition and regenerate client types afterwards.
How long does the database migration take?
Minutes for most installations. Large databases take longer; measure on a production copy during the staging upgrade.
What happens to numeric ids?
They still exist in responses, but documentId is the identifier to use for routes, caching and references across versions.