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:

// 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"]
}
}
}

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

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: 3, // author document ID
tags: [1, 2, 5], // tag document IDs
},
}),
});

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: [{ id: 7 }], // add tag 7
disconnect: [{ id: 2 }], // remove tag 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: [{ id: 1 }, { id: 3 }, { 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) {
article.author = await strapi.documents('api::author.author').findOne(article.authorId);
}

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

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...Set explicit maxDepth or break the chain with fields
Forgetting dynamic zone on syntaxReturns empty array for dynamic zonesUse the on key with component UIDs
Filtering on unpopulated relationFilter silently fails or returns wrong resultsEnsure the relation exists in the query context

See also