A Strapi 5 tutorial that skips the hello-world content type and covers what actually breaks in month four: populate depth, documentId, draft and publish leaks, media providers, and webhook revalidation into Next.js.
The ticket said the careers page was empty. The API was returning 200. The dynamic zone was there, four components long, in the right order, and every image inside every one of them came back as an empty object. Someone had shipped populate=* and assumed it recursed. It does not, it resolves one level, so Strapi returned the right keys with nothing in them and the page rendered as nine hundred pixels of white instead of throwing a stack trace anyone could have grepped. That is what month four of a Strapi build looks like, and no tutorial covers it, because every tutorial stops at minute twenty: run create-strapi-app, add an Article with a title and a rich text field, tick the public role, curl the endpoint, ship the screenshot.
We build these backends for clients who hand the admin panel to a two-person marketing team and then stop calling developers, so the failure modes we optimise against are the maintenance ones. This is the set of decisions that cost nothing in week one and cost a data migration in month three: how to shape content types around the editing session rather than around your tables, why the query layer holds most of the bugs, the three deploy details that reliably eat launch day, and how to make the cache invalidate itself. Strapi 5 throughout, because the v4 to v5 shift moved identity to documentId and dropped the data.attributes wrapper, and an old post will confidently teach you both of the wrong ones.
Coming from Postgres, the instinct is to normalise: a Page table, a Section table, a foreign key, an order column. Strapi has a better primitive, and it is the dynamic zone, an ordered array of components the editor can add, drag, and delete, where every entry carries a __component discriminant like sections.hero or sections.pricing-table. Your frontend switches on that string. Done right, the marketing team ships a new landing page on a Tuesday afternoon and nobody opens a ticket, which is the whole reason anyone pays for a CMS.
Two rules keep dynamic zones from turning to sludge. First, that UID is stored as a literal string in the components join table, so renaming sections.hero once content exists is an UPDATE across every row that used it, not an IDE refactor. Name components by job, not by looks: sections.logo-wall, never sections.grey-strip. Second, budget the component count. We cap new builds at roughly twelve section components, because each one is a renderer, a props contract, an empty state, and a preview path, and past a dozen editors stop reading the picker and just duplicate last month's page. A repeatable component nested inside a section absorbs most of the variation people actually ask for.
Schema lives in code at src/api/<type>/content-types/<type>/schema.json and that file is the source of truth. Build types in the admin panel locally, commit the generated JSON, and let Strapi sync it on boot during deploy. Strapi already disables the content-type builder when NODE_ENV is production; do not undo that. What never syncs is admin configuration, roles, and permissions, which live in database tables and drift silently between staging and production until someone re-ticks forty checkboxes after a fresh deploy. Either run config-sync or write down who owns the re-ticking.
Strapi returns nothing relational by default: no media, no relations, no components. Newcomers find populate=* and believe it is solved, and it is, for exactly one level. Wildcard a dynamic zone and you get the components but not the image inside the hero and not the linked case study inside the testimonial. Empty values are worse than an error because they render as a blank section on a client's homepage rather than a 500 your monitoring catches. Write explicit nested populate with qs, or better, put the query inside a custom controller so no frontend developer can drop a level on a Friday.
Once populate runs three levels deep on a list endpoint, count the queries. The Document Service is not a lazy ORM you can hand-wave about: a thirty-item article list with nested authors, avatars, and a two-level zone measured over four hundred queries and multi-second TTFB for us. Fetch the full tree only on single-entry routes, keep list routes thin, and select exactly the fields the card renders. That one habit took a client list response from eight hundred kilobytes to twelve.
The v5 change to internalise is documentId. Every entry now has a numeric id and a documentId, and only the documentId is stable across locales and across the draft and published rows of the same entry, which means anything keyed on id breaks the moment an editor publishes. Key your cache tags, your redirects, and your related-content links on documentId. Note too that v5 serves published content by default and takes status=draft for the editing view, so preview must run through an authenticated server-side route. If your public API token can read drafts, the unreleased pricing page is one crawler away from being the launch announcement.
One: the default SQLite file. Fine locally, a trap in a container, because the filesystem is ephemeral and the content the client spent a week entering disappears on the next redeploy. Move to Postgres before anyone writes real content, and move data between environments with strapi transfer instead of hand-rolled seed scripts. Two: the upload provider. Local uploads land in public/uploads on that same disposable disk, and the database stores relative paths like /uploads/hero_a91f3c.jpg. Switch to S3 or Cloudinary before launch, because doing it afterwards means rewriting URLs across every media row and every rich text body that inlined one in markdown.
Three: the admin build. The admin panel is a Vite bundle compiled at build time, and its public environment variables are baked into that bundle, not read at boot. Miss one in your Docker build args and you ship an admin pointing at the wrong API URL with no runtime fix and no path forward but a rebuild. While you are in there, stop serving your frontend from the public role, which is a global switch: ticking find on one type opens it to every scraper on the internet. Issue a read-only API token scoped to the endpoints your site actually calls, keep it server-side, and you get revocation and an audit trail instead of a checkbox.
A headless CMS where the editor cannot see their own change is a downgrade from WordPress, and that is exactly what statically generated pages with no invalidation path deliver. Strapi fires webhooks on entry create, update, publish, and unpublish. Point them at a Next.js route handler gated by a shared secret header, map the model name in the payload to a cache tag, call revalidateTag. Publish, refresh, see it. Ten minutes of work that decides whether the client thinks the rebuild was worth paying for.
Build preview with the same discipline: a route that sets draft mode, fetches with status=draft using the server-side token, and renders the real components, so editors review in context instead of guessing from a rich text box. Then add lifecycles.js next to the schema and enforce the rules the model cannot express, like a case study reaching published with no hero image or a slug colliding with an existing locale. Strapi will let both through. A beforeUpdate hook that throws an ApplicationError will not, and it is about fifteen lines.
A software studio that ships and maintains its own products — KeepChats, Gwora and Cairn — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →Send the problem. You get one fixed number and a plan back within a business day.
We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar