Introduction
Strapi isn’t just one of the most popular open-source headless CMSs—it’s also a massive codebase with hundreds of contributors and thousands of pull requests. Maintaining quality at that scale doesn’t happen by accident. It requires clear, repeatable code review rules that keep contributions consistent, secure, and maintainable.
In this article, we’ve collected code review rules inspired by Strapi’s public repository. These are not abstract best practices—they’re battle-tested lessons from real issues, discussions, and pull requests in the Strapi repo.
Why Maintaining Code Quality in a Large Open-Source Project is Hard
Maintaining quality in a large open-source project is challenging because of the sheer scale and diversity of contributions. Hundreds or even thousands of developers, from volunteers to seasoned engineers, submit pull requests, each introducing new features, bug fixes, or refactors. Without clear rules, the codebase can quickly become inconsistent, brittle, or difficult to navigate.
Some of the main challenges include:
- Diverse contributors with varying experience levels.
- Inconsistent coding patterns across modules.
- Hidden bugs and duplicated logic creeping in.
- Security risks if processes aren’t enforced.
- Time-intensive reviews for volunteers unfamiliar with the full codebase.
To address these challenges, successful projects rely on structured processes: shared standards, automated tooling, and clear guidelines. These practices ensure maintainability, readability, and security even as the project grows and attracts more contributors.
How Following These Rules Improves Maintainability, Security, and Onboarding
Adhering to a clear set of code review rules has a direct impact on the health of your project:
- Maintainability: Consistent folder structures, naming conventions, and coding patterns make it easier to read, navigate, and extend the codebase.
- Security: Input validation, sanitization, permission checks, and controlled database access reduce vulnerabilities and prevent accidental data leaks.
- Faster Onboarding: Shared standards, documented utilities, and clear examples help new contributors understand the project quickly and contribute confidently.
By applying these rules, teams can ensure that the codebase remains scalable, reliable, and secure, even as the number of contributors grows.
Bridging Context to Rules
Before diving into the rules, it’s important to understand that maintaining code quality in a project like Strapi isn’t just about following generic best practices—it’s about enforcing patterns and standards that keep hundreds of contributors aligned. Each of the 20 rules below addresses common challenges observed in Strapi’s repository.
The examples provided for each rule illustrate both non-compliant and compliant approaches, giving a clear picture of how these principles apply in practice.
Now, let’s explore the rules that make Strapi’s codebase scalable, consistent, and high-quality, starting with project structure and configuration standards.
Project Structure & Consistency
1. Follow Strapi’s Established Folder Conventions
Avoid scattering files or inventing new structures. Stick to Strapi’s established project layout to keep navigation predictable.
❌ Non Compliant example
1src/
2├── controllers/
3│ └── userController.js
4├── services/
5│ └── userLogic.js
6├── routes/
7│ └── userRoutes.js
8└── utils/
9 └── helper.js
✅ Compliant example
1src/
2└── api/
3 └── user/
4 ├── controllers/
5 │ └── user.js
6 ├── services/
7 │ └── user.js
8 ├── routes/
9 │ └── user.js
10 └── content-types/
11 └── user/schema.json
2. Keep Configuration Files Consistent
Use the same structure, naming, and formatting conventions in all configuration files to ensure consistency and prevent errors.
❌ Non Compliant example
1// config/server.js
2module.exports = {
3 PORT: 1337,
4 host: '0.0.0.0',
5 APP_NAME: 'my-app'
6}
7
8// config/database.js
9export default {
10 connection: {
11 client: 'sqlite',
12 connection: { filename: '.tmp/data.db' }
13 }
14}
15
16// config/plugins.js
17module.exports = ({ env }) => ({
18 upload: { provider: "local" },
19 email: { provider: 'sendgrid' }
20});
✅ Compliant example
1// config/server.js
2module.exports = ({ env }) => ({
3 host: env('HOST', '0.0.0.0'),
4 port: env.int('PORT', 1337),
5 app: { keys: env.array('APP_KEYS') },
6});
7
8// config/database.js
9module.exports = ({ env }) => ({
10 connection: {
11 client: 'sqlite',
12 connection: { filename: env('DATABASE_FILENAME', '.tmp/data.db') },
13 useNullAsDefault: true,
14 },
15});
16
17// config/plugins.js
18module.exports = ({ env }) => ({
19 upload: { provider: 'local' },
20 email: { provider: 'sendgrid' },
21});
3. Maintain Strict Type Safety
All new or updated code must include accurate TypeScript types or JSDoc definitions. Avoid using any, missing return types, or implicit type inference in shared modules.
❌ Non Compliant example
1// src/api/user/services/user.ts
2export const createUser = (data) => {
3 return strapi.db.query('api::user.user').create({ data });
4};
✅ Compliant example
1// src/api/user/services/user.ts
2import { User } from './types';
3
4export const createUser = async (data: User): Promise<User> => {
5 return await strapi.db.query('api::user.user').create({ data });
6};
4. Consistent Naming for Services and Controllers
Controller and service names must clearly match their domain (e.g., user.controller.js with user.service.js).
❌ Non Compliant example
1src/
2└── api/
3 └── user/
4 ├── controllers/
5 │ └── mainController.js
6 ├── services/
7 │ └── accountService.js
8 ├── routes/
9 │ └── user.js
✅ Compliant example
1src/
2└── api/
3 └── user/
4 ├── controllers/
5 │ └── user.js
6 ├── services/
7 │ └── user.js
8 ├── routes/
9 │ └── user.js
10 └── content-types/
11 └── user/schema.json
Code Quality & Maintainability
5. Simplify Control Flow with Early Returns
Instead of deep if/else nesting, return early when conditions fail.
❌ Non Compliant example
1// src/api/article/controllers/article.js
2module.exports = {
3 async create(ctx) {
4 const { title, content, author } = ctx.request.body;
5
6 if (title) {
7 if (content) {
8 if (author) {
9 const article = await strapi.db.query('api::article.article').create({
10 data: { title, content, author },
11 });
12 ctx.body = article;
13 } else {
14 ctx.throw(400, 'Missing author');
15 }
16 } else {
17 ctx.throw(400, 'Missing content');
18 }
19 } else {
20 ctx.throw(400, 'Missing title');
21 }
22 },
23};
✅ Compliant example
1// src/api/article/controllers/article.js
2module.exports = {
3 async create(ctx) {
4 const { title, content, author } = ctx.request.body;
5
6 if (!title) ctx.throw(400, 'Missing title');
7 if (!content) ctx.throw(400, 'Missing content');
8 if (!author) ctx.throw(400, 'Missing author');
9
10 const article = await strapi.db.query('api::article.article').create({
11 data: { title, content, author },
12 });
13
14 ctx.body = article;
15 },
16};
6. Avoid Excessive Nesting in Controllers
Avoid large blocks of nested logic inside controllers or services. Extract repeated or complex conditions into well-named helper functions or utilities.
❌ Non Compliant example
1// src/api/order/controllers/order.js
2module.exports = {
3 async create(ctx) {
4 const { items, user } = ctx.request.body;
5
6 if (user && user.role === 'customer') {
7 if (items && items.length > 0) {
8 const stock = await strapi.service('api::inventory.inventory').checkStock(items);
9 if (stock.every((i) => i.available)) {
10 const order = await strapi.db.query('api::order.order').create({ data: { items, user } });
11 ctx.body = order;
12 } else {
13 ctx.throw(400, 'Some items are out of stock');
14 }
15 } else {
16 ctx.throw(400, 'No items in order');
17 }
18 } else {
19 ctx.throw(403, 'Unauthorized user');
20 }
21 },
22};
✅ Compliant example
1// src/api/order/utils/validation.js
2const isCustomer = (user) => user?.role === 'customer';
3const hasItems = (items) => Array.isArray(items) && items.length > 0;
4
5// src/api/order/controllers/order.js
6module.exports = {
7 async create(ctx) {
8 const { items, user } = ctx.request.body;
9
10 if (!isCustomer(user)) ctx.throw(403, 'Unauthorized user');
11 if (!hasItems(items)) ctx.throw(400, 'No items in order');
12
13 const stock = await strapi.service('api::inventory.inventory').checkStock(items);
14 const allAvailable = stock.every((i) => i.available);
15 if (!allAvailable) ctx.throw(400, 'Some items are out of stock');
16
17 const order = await strapi.db.query('api::order.order').create({ data: { items, user } });
18 ctx.body = order;
19 },
20};
7. Keep Business Logic Out of Controllers
Controllers should remain thin and only orchestrate requests. Move business logic to services.
❌ Non Compliant example
1// src/api/article/controllers/article.js
2module.exports = {
3 async create(ctx) {
4 const { title, content, authorId } = ctx.request.body;
5
6 const author = await strapi.db.query('api::author.author').findOne({ where: { id: authorId } });
7 if (!author) ctx.throw(400, 'Author not found');
8
9 const timestamp = new Date().toISOString();
10 const slug = title.toLowerCase().replace(/\s+/g, '-');
11
12 const article = await strapi.db.query('api::article.article').create({
13 data: { title, content, slug, publishedAt: timestamp, author },
14 });
15
16 await strapi.plugins['email'].services.email.send({
17 to: author.email,
18 subject: `New article: ${title}`,
19 html: `<p>${content}</p>`,
20 });
21
22 ctx.body = article;
23 },
24};
✅ Compliant example
1// src/api/article/controllers/article.js
2module.exports = {
3 async create(ctx) {
4 const article = await strapi.service('api::article.article').createArticle(ctx.request.body);
5 ctx.body = article;
6 },
7};
// src/api/article/services/article.js
module.exports = ({ strapi }) => ({
async createArticle(data) {
const { title, content, authorId } = data;
const author = await strapi.db.query('api::author.author').findOne({ where: { id: authorId } });
if (!author) throw new Error('Author not found');
const slug = title.toLowerCase().replace(/\s+/g, '-');
const article = await strapi.db.query('api::article.article').create({
data: { title, content, slug, author },
});
await strapi.plugins['email'].services.email.send({
to: author.email,
subject: `New article: ${title}`,
html: `<p>${content}</p>`,
});
return article;
},
});
8. Use Utility Functions for Repeated Patterns
Duplicate patterns (e.g., validation, formatting) should live in shared utilities.
❌ Non Compliant example
// src/api/article/controllers/article.js
module.exports = {
async create(ctx) {
const { title } = ctx.request.body;
const slug = title.toLowerCase().replace(/\s+/g, '-');
ctx.body = await strapi.db.query('api::article.article').create({ data: { ...ctx.request.body, slug } });
},
};
// src/api/event/controllers/event.js
module.exports = {
async create(ctx) {
const { name } = ctx.request.body;
const slug = name.toLowerCase().replace(/\s+/g, '-');
ctx.body = await strapi.db.query('api::event.event').create({ data: { ...ctx.request.body, slug } });
},
};
✅ Compliant example
// src/utils/slugify.js
module.exports = (text) => text.toLowerCase().trim().replace(/\s+/g, '-');
// src/api/article/controllers/article.js
const slugify = require('../../../utils/slugify');
module.exports = {
async create(ctx) {
const { title } = ctx.request.body;
const slug = slugify(title);
ctx.body = await strapi.db.query('api::article.article').create({ data: { ...ctx.request.body, slug } });
},
};
9. Remove Debugging Logs Before Production
Do not use console.log, console.warn, or console.error in production code.Always use strapi.log or a configured logger to ensure logs respect environment settings and avoid exposing sensitive information.
❌ Non Compliant example
// src/api/user/controllers/user.js
module.exports = {
async find(ctx) {
console.log('Request received:', ctx.request.body); // Unsafe in production
const users = await strapi.db.query('api::user.user').findMany();
console.log('Users fetched:', users.length);
ctx.body = users;
},
};
✅ Compliant example
// src/api/user/controllers/user.js
module.exports = {
async find(ctx) {
strapi.log.info(`Fetching users for request from ${ctx.state.user?.email || 'anonymous'}`);
const users = await strapi.db.query('api::user.user').findMany();
strapi.log.debug(`Number of users fetched: ${users.length}`);
ctx.body = users;
},
};
if (process.env.NODE_ENV === 'development') {
strapi.log.debug('Request body:', ctx.request.body);
}
Database & Query Practices
Do not execute raw SQL queries in controllers or services.Always use a consistent, high-level query method (such as an ORM or query builder) to ensure maintainability, enforce rules/hooks, and reduce security risks.
❌ Non Compliant example
// src/api/user/services/user.js
module.exports = {
async findActiveUsers() {
const knex = strapi.db.connection;
const result = await knex.raw('SELECT * FROM users WHERE active = true'); // Raw SQL
return result.rows;
},
};
✅ Compliant example
// src/api/user/services/user.js
module.exports = {
async findActiveUsers() {
return await strapi.db.query('api::user.user').findMany({
where: { active: true },
});
},
};
11. Use Strapi’s Query Engine Consistently
Do not mix different database access methods (e.g., ORM calls vs. raw queries) within the same feature.Use a single, consistent query approach to ensure maintainability, readability, and predictable behavior.
❌ Non Compliant example
// src/api/order/services/order.js
module.exports = {
async getPendingOrders() {
// Using entityService
const orders = await strapi.entityService.findMany('api::order.order', {
filters: { status: 'pending' },
});
// Mixing with raw db query
const rawOrders = await strapi.db.connection.raw('SELECT * FROM orders WHERE status = "pending"');
return { orders, rawOrders };
},
};
✅ Compliant example
// src/api/order/services/order.js
module.exports = {
async getPendingOrders() {
return await strapi.db.query('api::order.order').findMany({
where: { status: 'pending' },
});
},
};
Batch related database queries or combine them into a single operation to prevent performance bottlenecks and reduce unnecessary sequential calls.
❌ Non Compliant example
async function getArticlesWithAuthors() {
const articles = await db.query('articles').findMany();
// Fetch author for each article sequentially
for (const article of articles) {
article.author = await db.query('authors').findOne({ id: article.authorId });
}
return articles;
}
✅ Compliant example
async function getArticlesWithAuthors() {
return await db.query('articles').findMany({ populate: ['author'] });
}
API & Security
13. Validate Input with Strapi Validators
Never trust input from clients or external sources.Validate all incoming data using a consistent validation mechanism before using it in controllers, services, or database operations.
❌ Non Compliant example
async function createUser(req, res) {
const { username, email } = req.body;
// Directly inserting into database without validation
const user = await db.query('users').create({ username, email });
res.send(user);
}
✅ Compliant example
const Joi = require('joi');
async function createUser(req, res) {
const schema = Joi.object({
username: Joi.string().min(3).required(),
email: Joi.string().email().required(),
});
const { error, value } = schema.validate(req.body);
if (error) return res.status(400).send(error.details);
const user = await db.query('users').create(value);
res.send(user);
}
14. Sanitize User Input Before Saving
Sanitize all input before saving it to the database or passing it to other systems.
❌ Non Compliant example
async function createComment(req, res) {
const { text, postId } = req.body;
// Directly saving data
const comment = await db.query('comments').create({ text, postId });
res.send(comment);
}
✅ Compliant example
const sanitizeHtml = require('sanitize-html');
async function createComment(req, res) {
const { text, postId } = req.body;
const sanitizedText = sanitizeHtml(text, { allowedTags: [], allowedAttributes: {} });
const comment = await db.query('comments').create({ text: sanitizedText, postId });
res.send(comment);
}
Apply permission checks on every protected route to ensure only authorized users can access it.
❌ Non Compliant example
async function deleteUser(req, res) {
const { userId } = req.params;
// No check for admin or owner
await db.query('users').delete({ id: userId });
res.send({ success: true });
}
✅ Compliant example
async function deleteUser(req, res) {
const { userId } = req.params;
const requestingUser = req.user;
// Allow only admins or the owner
if (!requestingUser.isAdmin && requestingUser.id !== userId) {
return res.status(403).send({ error: 'Forbidden' });
}
await db.query('users').delete({ id: userId });
res.send({ success: true });
}
16. Consistent Error Handling with Boom
Handle errors consistently across all API routes using a centralized or unified error-handling mechanism.
❌ Non Compliant example
async function getUser(req, res) {
const { id } = req.params;
try {
const user = await db.query('users').findOne({ id });
if (!user) res.status(404).send('User not found'); // raw string error
else res.send(user);
} catch (err) {
res.status(500).send(err.message); // different error format
}
}
✅ Compliant example
const { createError } = require('../utils/errors');
async function getUser(req, res, next) {
try {
const { id } = req.params;
const user = await db.query('users').findOne({ id });
if (!user) throw createError(404, 'User not found');
res.send(user);
} catch (err) {
next(err); // passes error to centralized error handler
}
}
// src/utils/errors.js
function createError(status, message) {
return { status, message };
}
function errorHandler(err, req, res, next) {
res.status(err.status || 500).json({ error: err.message });
}
module.exports = { createError, errorHandler };
Testing & Documentation
17. Add or Update Tests for Every Feature
New code without tests won’t get merged—tests are part of the definition of done.
❌ Non Compliant example
// src/api/user/services/user.js
module.exports = {
async createUser(data) {
const user = await db.query('users').create(data);
return user;
},
};
// No test file exists for this service
✅ Compliant example
// tests/user.service.test.js
const { createUser } = require('../../src/api/user/services/user');
describe('User Service', () => {
it('should create a new user', async () => {
const mockData = { username: 'testuser', email: 'test@example.com' };
const result = await createUser(mockData);
expect(result).toHaveProperty('id');
expect(result.username).toBe('testuser');
expect(result.email).toBe('test@example.com');
});
});
Every API addition must be documented in the reference docs before merge.
❌ Non Compliant example
// src/api/user/controllers/user.js
module.exports = {
async deactivate(ctx) {
const { userId } = ctx.request.body;
await db.query('users').update({ id: userId, active: false });
ctx.body = { success: true };
},
};
// No update in API reference or docs
✅ Compliant example
// src/api/user/controllers/user.js
module.exports = {
/**
* Deactivate a user account.
* POST /users/deactivate
* Body: { userId: string }
* Response: { success: boolean }
* Errors: 400 if userId missing, 404 if user not found
*/
async deactivate(ctx) {
const { userId } = ctx.request.body;
if (!userId) ctx.throw(400, 'userId is required');
const user = await db.query('users').findOne({ id: userId });
if (!user) ctx.throw(404, 'User not found');
await db.query('users').update({ id: userId, active: false });
ctx.body = { success: true };
},
};
Reference Docs Update Example:
### POST /users/deactivate
**Request Body:**
```json
{
"userId": "string"
}
Response:
{
"success": true
}
Errors:
- 400: userId is required
- 404: User not found
Why this works:
- Developers and API consumers can discover and use endpoints reliably
- Ensures consistency between implementation and docs
- Makes maintenance and onboarding easier
---
Do you want me to continue with **Rule #19 (“Use JSDoc for Shared Utilities”)** in the same format next?
19. Use JSDoc for Shared Utilities
Shared functions should be explained with JSDoc to ease onboarding and collaboration.
❌ Non Compliant example
// src/utils/slugify.js
function slugify(text) {
return text.toLowerCase().trim().replace(/\s+/g, '-');
}
module.exports = slugify;
✅ Compliant example
// src/utils/slugify.js
/**
* Converts a string into a URL-friendly slug.
*
* @param {string} text - The input string to convert.
* @returns {string} A lowercased, trimmed, dash-separated slug.
*/
function slugify(text) {
return text.toLowerCase().trim().replace(/\s+/g, '-');
}
module.exports = slugify;
20. Update Changelog with Every Significant PR
Update the project’s changelog with every significant feature, bug fix, or API change before merging a PR.
❌ Non Compliant example
# CHANGELOG.md
## [1.0.0] - 2025-09-01
- Initial release
✅ Compliant example
# CHANGELOG.md
## [1.1.0] - 2025-10-06
- Added user deactivation endpoint (`POST /users/deactivate`)
- Fixed bug in slug generation for article titles
- Updated email notification service to handle batch sending
Conclusion
We analyzed Strapi’s public repository to identify how structured code patterns can help scale an open-source project to thousands of contributors without losing quality. These 20 rules aren’t theoretical—they’re battle-tested patterns observed in Strapi’s codebase that improve maintainability, security, and readability.
If your project is growing, steal these lessons. Apply them to your reviews, and you’ll spend less time fighting messy code and more time shipping features that matter.