Available — part-time slots from Q4 2026 UK · Remote BST · GMT+1
All fixes
F/02 8 min read reviewed Sep 2026· Strapi 5

What to use instead of populate=deep

TL;DR

populate=deep is a community plugin, not a Strapi feature, and it isn't on the Marketplace for a reason. It works by removing the depth ceiling that Strapi deliberately puts on population. That trade buys you a few days of convenience early and hands you an endpoint whose cost is set by your schema rather than by your request. The replacement is explicit population, declared once per endpoint in a route middleware rather than scattered across your front end. This guide covers what to write instead, including the dynamic-zone syntax that trips most people up, and how to migrate without a rewrite.

00· Before anything

First: confirm population is your cost

It usually is, but "the API is slow" has five common causes and only one of them is this. If you haven't ruled out the others yet, start with Why is your Strapi API slow? and come back.

The fastest confirmation: turn on query logging, hit the endpoint once, and count the queries. If a single request is producing dozens of SQL statements with joins several levels deep, you're in the right article.

A second signal, and the one that's diagnostic rather than suggestive: the endpoint got slower and nobody changed it. If response time on a route degraded in a week where the only commits touched a content type somewhere else, that's the depth ceiling being absent.

01

What the plugin actually does

Strapi's REST API returns top-level scalar fields and nothing else. Relations, media, components and dynamic zones are opt-in: you ask for them with populate.

populate=* is the shortcut, and it stops after one level. The docs are explicit about why: it populates everything "1 level deep, to prevent performance issues and long response times." That ceiling is a feature. It means a request can only ever be so expensive.

The populate-deep plugins remove it. You pass a depth number, it walks your schema recursively and builds the population object for you. So populate=deep,5 means "follow every relation, component and dynamic zone five levels down, whatever is there."

That's the whole mechanism, and it's worth being precise about it because the plugin isn't doing anything clever or dangerous internally. It's building a query you could have written yourself. The problem isn't the query it builds today. It's that you didn't write it, so you don't know what it is, and it changes without you.

These plugins are deliberately absent from the Strapi Marketplace. Strapi's own support documentation on this is blunt: deep population "trades short-term simplicity for long-term instability."

02

The three safeguards it removes

Safeguard one: the depth ceiling. Without a ceiling, query cost is bounded by your content model rather than by anything in the request. Strapi's guidance is to populate explicitly and limit depth to two or three levels in production, and separately that population depth beyond two or three levels should be read as a signal to revisit the content model rather than as a query to optimise.

The plugin makes that signal invisible. A model that would have made you stop and think produces a working endpoint instead, and you find out at scale.

Safeguard two: cost changes with your schema, not your request. This is the one that actually bites, and it's why the failure shows up as a mystery rather than as a regression.

When population is explicit, a request describes its own cost. Adding a repeatable component to a content type changes nothing for any endpoint that didn't ask for it. When population is deep, that same change adds joins and subqueries to every endpoint that walks through that content type, at every depth level that reaches it.

So the degradation is decoupled from the change that caused it. A developer adds a component to a nested content type on Tuesday; an unrelated listing endpoint gets slower on Wednesday; nobody connects the two, because nothing in that endpoint's code was touched. Strapi's own write-up of this puts it exactly right: queries "become unbounded and grow in cost as schemas evolve," and "small schema or frontend changes can cause large performance regressions."

Dynamic zones and repeatable components multiply this, because each nested component adds its own joins and subqueries. A dynamic zone with eight component options inside a deep population isn't one query shape: it's eight, and whichever ones are present on the entry you requested.

Safeguard three: nobody owns population. The quiet one, and in my experience the reason teams can't get off it later.

With explicit population, every endpoint has a statement of what the front end needs, written down, in one place, attributable to a commit. You can ask "who needs this relation?" and get an answer. You can delete a field and find out what breaks.

With populate=deep, that statement doesn't exist anywhere. The front end receives everything and picks what it wants at render time. Six months later nobody can tell you which of the forty populated relations on your article endpoint is actually rendered, so nobody can safely remove any of them, and the migration you want to do keeps getting deferred because the first step is archaeology.

That's also why the payload is usually the visible symptom before latency is. Response bodies full of fields no component reads.

03

Explicit population, the mechanics

The replacement isn't exotic. It's the documented syntax, written once.

Array notation, for a flat list of relations one level down:

js
{
  populate: ["image", "category", "seo", "blocks"]
}

Object notation, which is what you want as soon as anything nests:

js
{
  populate: {
    image: true,
    category: true,
    seo: {
      populate: ["metaImage"]
    }
  }
}

Add fields wherever you populate. This is the step most teams skip, and it's where the payload savings actually live: populating a media relation without constraining fields pulls the entire upload record, formats object included, when you wanted a URL and an alt text:

js
{
  fields: ["title", "slug"],
  populate: {
    image: {
      fields: ["url", "alternativeText"]
    },
    author: {
      fields: ["name"]
    }
  }
}

As a query string, the same thing in LHS bracket notation:

text
?fields[0]=title&fields[1]=slug
&populate[image][fields][0]=url
&populate[image][fields][1]=alternativeText
&populate[author][fields][0]=name

Which is horrible to read, and is a large part of why people reach for the plugin in the first place. Section 05 is about not writing this in your front end at all.

04

Components and dynamic zones

This is the part that sends people back to the plugin, so it's worth getting right.

Components populate with dot notation for their nested relations:

js
{
  populate: ["seo.metaImage"]
}

Dynamic zones are different, because Strapi can't know generically which component types to populate. populate: { blocks: true } on a dynamic zone will error: it can't resolve what to do. You have two options: "*", which gets you one level inside each component, or the on syntax, which lets you specify population per component type:

js
{
  populate: {
    blocks: {
      on: {
        "layout.hero": {
          fields: ["heading", "text"],
          populate: {
            image: { fields: ["url", "alternativeText"] },
            buttonLink: true
          }
        },
        "layout.card-grid": {
          populate: {
            cardItems: { fields: ["heading", "text"] }
          }
        },
        "layout.section-heading": {
          populate: "*"
        }
      }
    }
  }
}

Two things worth noticing here.

The first is that this is more verbose than populate=deep and that verbosity is the product. Each line is a decision someone made about what a page needs. The hero pulls its image at two fields; the card grid pulls its items at two fields; the section heading is small enough not to bother. None of that survives in a recursive walk.

The second is that on lets you leave component types out entirely. If a dynamic zone has eight options and a given endpoint only ever renders three of them, populate three. Deep population has no way to express that, because it doesn't know which ones you render, only which ones exist.

05

Put the decision on the server

Here's the thing the plugin is genuinely solving, and if you skip this step you'll end up back on it within a quarter.

Nobody wants to write a forty-line population object in their front-end fetch call. And once several people are writing their own, you get exactly the drift Strapi describes: the same collection fetched three different ways from three different components:

text
GET /api/articles?populate=*
GET /api/articles?populate[cover]=true
GET /api/articles?populate[blocks][populate]=*

Three shapes, three costs, no owner.

The fix is to declare population once, on the server, in a route middleware:

./src/api/article/middlewares/article-populate.js js
export default () => {
  return async (ctx, next) => {
    ctx.query = {
      ...ctx.query,
      fields: ["title", "slug"],
      populate: {
        cover: { fields: ["url"] },
        author: { fields: ["name"] },
      },
    };
    await next();
  };
};

Register it on the route, and the front end goes back to calling GET /api/articles.

What this buys you is the thing section 02 said you'd lost. Population now has a file, an owner and a commit history. A change to it is reviewable. A performance regression has a diff you can point at. And the front end gets the simple call it wanted in the first place, which was the actual requirement all along, not recursive population.

Note the spread in that example. Overwriting ctx.query wholesale rather than merging will silently discard pagination, filters and sort that the caller sent. Merge, then set the keys you're taking ownership of.

If you need genuinely different shapes for different pages, that's a fine reason to have more than one route: a /api/articles for listings and a detail route with a heavier population object beats one endpoint that serves both badly.

06· The migration

Getting off it without a rewrite

The migration people imagine is "write population objects for every endpoint, then remove the plugin." That's a big-bang project that never gets scheduled. Do it in this order instead:

  1. Measure first. Turn on query logging and capture what the plugin is actually building for your top few endpoints. You need this anyway, and it makes the next step mechanical rather than speculative.
  2. Rank by traffic, not by ugliness. The endpoint powering your listing page at fifty requests a second matters more than the deeply nested one an editor hits twice a day. Two or three endpoints usually account for most of the cost.
  3. Write the middleware for one endpoint. Compare the response against what the front end actually renders, not against what the plugin returned. This is where you discover the twenty relations nobody reads.
  4. Leave the plugin installed while you migrate. Routes with a population middleware don't need it. Routes without one keep working. There's no flag day.
  5. Remove it once the list is empty, and treat any route you couldn't write population for as a content-model question rather than a migration failure. If you genuinely can't describe what an endpoint needs, that's the safeguard doing its job.

07

If it's still slow after this

Explicit population is necessary and sometimes not sufficient. In rough order of what I'd check next:

Your content model. If the honest population object is still five levels deep, the model is the problem. Deeply nested component trees and recursive relations are where most Strapi performance pain lives. Data that's always consumed as a single unit and never queried independently (opening hours, configuration blobs, structured settings) often belongs in a custom JSON field, where it costs one column instead of a join per level.

Field selection. Populating without fields still over-fetches. Check your media relations first; they're the worst offenders by payload.

Caching, last. The REST Cache plugin is a real option for read-heavy public endpoints. Strapi doesn't cache queries at the application layer by design. But caching an inefficient query gives you a fast wrong answer most of the time and a slow one at every invalidation: it should reinforce a good query shape, not substitute for one.

And if none of that moves it, the bottleneck may not be population at all. Back to the diagnostic guide.

Common questions

Diagnosed everything and still stuck?

S/02 — Performance & Architecture Rescue.

A fixed-scope week that finds the cause and fixes what's causing it.