Skip to main content

Relations and Population

Relations are one of the most powerful - and most confusing - aspects of Strapi. Understanding how to define, query, and populate them efficiently is essential for any non-trivial project.

Relation types​

TypeExampleDatabase
One-to-OneUser has one ProfileFK on either table
One-to-ManyAuthor has many ArticlesFK on the "many" side
Many-to-ManyArticles have many Tags (and vice versa)Join table
Many-WayArticle references many related Articles (one-directional)Join table
PolymorphicComment belongs to Article or PageType + ID columns

Relations are configured in the Content-Type Builder or directly in the schema JSON:

File: src/api/article/content-types/article/schema.json

{
"attributes": {
"title": { "type": "string", "required": true },
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "articles"
},
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "api::tag.tag",
"inversedBy": "articles"
},
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
}
}
}

Use inversedBy on the owning side and mappedBy on the inverse side. For example, the tag schema can point back to the article.tags relation like this:

{
"attributes": {
"articles": {
"type": "relation",
"relation": "manyToMany",
"target": "api::article.article",
"mappedBy": "tags"
}
}
}

Population basics​

By default, Strapi returns flat data without relations populated. You must explicitly request populated fields.

REST API population​

# Populate one level
GET /api/articles?populate=author

# Populate multiple relations
GET /api/articles?populate[0]=author&populate[1]=tags

# Populate everything (use sparingly!)
GET /api/articles?populate=*

# Deep population (author and author's avatar)
GET /api/articles?populate[author][populate]=avatar

# Using the qs library for cleaner URLs

populate=* only populates one level deep. For deeper relation trees, components, or dynamic zones, spell out the nested populate object explicitly.

Using qs for complex queries​

The qs library generates the correct query string for deeply nested population:

import qs from 'qs';

const query = qs.stringify({
populate: {
author: {
populate: ['avatar'],
fields: ['name', 'email'],
},
tags: {
fields: ['name', 'slug'],
},
cover: true,
},
}, { encodeValuesOnly: true });

const res = await fetch(`/api/articles?${query}`);

Document Service population (backend)​

// In a service or controller
const articles = await strapi.documents('api::article.article').findMany({
populate: {
author: {
populate: ['avatar'],
fields: ['name', 'bio'],
},
tags: {
fields: ['name', 'slug'],
},
cover: true,
seo: {
populate: ['ogImage'],
},
},
});

Filtering on relations​

REST API​

# Articles by a specific author name
GET /api/articles?filters[author][name][$eq]=John

# Articles with a specific tag
GET /api/articles?filters[tags][slug][$in][0]=javascript&filters[tags][slug][$in][1]=typescript

# Articles by author with more than 100 followers
GET /api/articles?filters[author][followers][$gt]=100

Document Service​

const articles = await strapi.documents('api::article.article').findMany({
filters: {
author: {
name: { $contains: 'John' },
},
tags: {
slug: { $in: ['javascript', 'typescript'] },
},
$or: [
{ featured: true },
{ publishedAt: { $notNull: true } },
],
},
populate: ['author', 'tags'],
});

Sorting and pagination with relations​

import qs from 'qs';

const query = qs.stringify({
sort: ['publishedAt:desc', 'author.name:asc'],
pagination: {
page: 1,
pageSize: 25,
},
populate: {
author: { fields: ['name'] },
},
fields: ['title', 'slug', 'publishedAt'],
}, { encodeValuesOnly: true });

Creating and updating relations​

Setting a relation on create​

// REST API: POST /api/articles
const response = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
title: 'My New Article',
author: 'author-document-id',
tags: ['tag-document-id-1', 'tag-document-id-2', 'tag-document-id-5'],
},
}),
});

Updating relations​

// Connect, disconnect, or set relations
const response = await fetch('/api/articles/abc123', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
tags: {
connect: [
{
documentId: 'tag-document-id-7',
position: { end: true },
},
],
disconnect: [{ documentId: 'tag-document-id-2' }],
},
},
}),
});

// Or replace all tags at once
const response2 = await fetch('/api/articles/abc123', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
tags: {
set: [
{ documentId: 'tag-document-id-1' },
{ documentId: 'tag-document-id-3' },
{ documentId: 'tag-document-id-7' },
],
},
},
}),
});

Backend service​

// Connect/disconnect in a service
await strapi.documents('api::article.article').update({
documentId,
data: {
tags: {
connect: [{ documentId: 'tag-abc' }],
disconnect: [{ documentId: 'tag-xyz' }],
},
},
});

Performance: avoiding N+1 queries​

Problem​

// BAD: N+1 queries - one query per article to fetch author
const articles = await strapi.documents('api::article.article').findMany({});
for (const article of articles) {
const [author] = await strapi.documents('api::author.author').findMany({
filters: {
articles: {
documentId: article.documentId,
},
},
limit: 1,
});
article.author = author;
}

Solution: populate upfront​

// GOOD: single query with population
const articles = await strapi.documents('api::article.article').findMany({
populate: {
author: { fields: ['name', 'avatar'] },
},
});

Selecting only needed fields​

// Only fetch what the frontend needs
const articles = await strapi.documents('api::article.article').findMany({
fields: ['title', 'slug', 'publishedAt'],
populate: {
author: { fields: ['name'] },
cover: { fields: ['url', 'alternativeText', 'width', 'height'] },
},
});

Default population with middleware​

Tired of specifying populate on every request? Set defaults:

// src/api/article/middlewares/default-populate.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
if (!ctx.query.populate) {
ctx.query.populate = {
author: { fields: ['name'] },
cover: { fields: ['url', 'alternativeText'] },
tags: { fields: ['name', 'slug'] },
};
}
await next();
};
};

Apply it to the find and findOne routes:

// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
config: {
find: {
middlewares: ['api::article.default-populate'],
},
findOne: {
middlewares: ['api::article.default-populate'],
},
},
});

Component and dynamic zone population​

Components and dynamic zones also need explicit population:

const page = await strapi.documents('api::page.page').findOne({
documentId,
populate: {
// Component
seo: {
populate: ['ogImage'],
},
// Dynamic zone
blocks: {
on: {
'blocks.hero': { populate: ['backgroundImage'] },
'blocks.text-with-image': { populate: ['image'] },
'blocks.gallery': { populate: ['images'] },
'blocks.cta': true,
},
},
},
});

REST API dynamic zone population​

GET /api/pages/abc123?populate[blocks][on][blocks.hero][populate]=backgroundImage&populate[blocks][on][blocks.text-with-image][populate]=image

To populate all first-level fields for a specific dynamic-zone component, use fragment syntax such as populate[blocks][on][blocks.hero]=*.

Or with qs:

const query = qs.stringify({
populate: {
blocks: {
on: {
'blocks.hero': { populate: ['backgroundImage'] },
'blocks.text-with-image': { populate: ['image'] },
'blocks.gallery': { populate: ['images'] },
},
},
},
}, { encodeValuesOnly: true });

Common pitfalls​

PitfallProblemFix
populate=* in productionFetches everything, huge payloads, slowExplicitly list needed relations and fields
Missing fields on populateReturns all attributes of the related entityUse fields: ['name'] to restrict
Circular populationAuthor populates articles which populate author...Spell out a finite populate tree and stop the cycle
Forgetting dynamic zone on syntaxReturns empty array for dynamic zonesUse the on key with component UIDs
Using numeric id in REST relationsWorks inconsistently in Strapi 5 Content API callsUse the stable documentId relation syntax

See also​