Aikido acquires Allseek & Haicker to lead race in autonomous AI pentesting →
Aikido

What We Learned from Strapi’s Codebase: 20 Code Review Rules for Scalable Development

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

10. Avoid Raw SQL Queries

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' },
    });
  },
};

12. Optimize Database Calls

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);
}

15. Enforce Permission Checks

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');
  });
});

18. Document New Endpoints

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.

FAQs

Don’t break the dev flow

Connect your task management, messaging tool, compliance suite & CI to track & solve issues in the tools you already use.

Why are code review rules important in open-source projects like Strapi?

Because hundreds of developers contribute, consistent review rules prevent chaos. They ensure every pull request follows the same structure, naming, and security patterns, keeping the project stable and maintainable over time.

How does Strapi maintain code quality at scale?

Strapi uses strict folder conventions, TypeScript adoption, automated tests, and standardized validation. Each change is reviewed for clarity, performance, and security before merging.

What’s the difference between linting and code review rules?

Linting catches syntax and formatting issues automatically. Code review rules go deeper—they address architecture, readability, naming clarity, maintainability, and security that tools can’t detect without human or AI-level context.

How can teams apply these rules to their own projects?

- Document your review standards.
- Add automated checks and PR templates.
- Use consistent naming and folder structures.
- Enforce tests and documentation updates in every merge.
- Run AI-based code reviews tools like Aikido Code Quality to catch high-level design issues.

What tools can help enforce code review rules automatically?

Tools like Aikido Security Code Quality help detect architectural, maintainability, and security issues during code reviews—going far beyond traditional linting.

What’s the biggest challenge in maintaining Strapi’s codebase?

Balancing innovation with consistency—new contributors bring fresh ideas, but without clear rules, the project risks diverging into inconsistent patterns and hard-to-maintain code.

How do these rules improve onboarding for new contributors?

They provide a clear roadmap of expectations. When folder structure, naming, and documentation are predictable, new developers can quickly understand how to contribute without breaking existing patterns.

Get secure for free

Secure your code, cloud, and runtime in one central system.
Find and fix vulnerabilities fast automatically.

No credit card required | Scan results in 32secs.