A query that returned nothing nested inside it
The exact same logical query returned full nested data through the REST layer and empty shells through the service layer underneath it. Same fields requested. Same backend. One shorthand parameter that only one of the two actually understood.
By Meet ModiPages fetched through a headless CMS's internal service-layer query API came back with SEO fields empty, nested content blocks empty, navigation data empty. Everything above the top level was just gone.
The exact same logical query, run through the CMS's public REST API instead, returned all of it correctly. Same fields requested, same underlying content, same backend process even, since the REST layer is just a thinner client sitting on top of the service layer I was calling directly.
The bug: a shorthand parameter that means "populate everything, however deep" only works at one of the two layers, and I was calling the other one.
Take 1: the shorthand that only works over REST
// calling the service layer directly, bypassing REST entirely
const page = await cmsService.findOne('api::page.page', pageId, {
populate: 'deep', // shorthand: populate every relation, however nested
});
console.log(page.seo); // undefined
console.log(page.contentBlocks); // []
console.log(page.navigation); // undefinedpopulate: 'deep' is real and documented, and it works, over REST. It's implemented by a plugin that intercepts REST requests specifically and expands the shorthand into the full nested populate object before the request reaches the underlying query engine. Calling the service layer directly skips the REST request-handling pipeline entirely, so the plugin never sees the query and never gets a chance to expand anything.
The shorthand doesn't error when it does nothing. It just quietly fails to expand, and the service layer treats the unrecognized shorthand as no populate instruction at all, so it returns only the page's own top-level fields.
Take 2: name every relation explicitly
The service layer supports the same nested-population, it just wants the structure spelled out as an object instead of accepting the shorthand. I replaced the shorthand with an explicit populate object naming every relation and every nested repeatable field the page actually needed.
const page = await cmsService.findOne('api::page.page', pageId, {
populate: {
seo: { populate: '*' },
navigation: { populate: { items: { populate: '*' } } },
contentBlocks: {
populate: {
media: true,
cards: { populate: '*' },
cta: true,
},
},
},
});
console.log(page.seo); // full object
console.log(page.contentBlocks); // fully populated arrayVerbose, and it has to be updated by hand whenever a new nested field gets added to any of these content types, which the shorthand never required. But it works against the layer I'm actually calling, which the shorthand never did.
What I should have done first
I should have checked which layer implemented populate: 'deep' before switching from REST to the service layer for performance reasons. The shorthand's convenience is exactly why it hides which layer it depends on: it looks like a query option, but it's actually a REST-specific expansion step, and nothing about the syntax gives that away. Skipping a layer to save a network hop is a reasonable move. Assuming every feature travels with it is not.
More Posts
The vector database was innocent
I built a RAG service over the OpenTelemetry docs, then pointed OpenTelemetry back at it to find out why answers took 16 seconds. It wasn't the LLM. It wasn't the vector search either.
One field, three different envelopes, and a fix that made it worse
The same subscription-status field came back nested two levels deep, one level deep, or bare at the top, depending on account state. My fix didn't catch that. It added math on top of a check that was already wrong.
The redirect that forgot where you were going
A user clicked a link straight to a class recordings page. Their session had expired. They logged back in and landed on the dashboard home instead, three clicks away from where they started.