Strapi v4 Custom API: Building Endpoints Beyond the Generated CRUD

Strapi v4 Custom API: Building Endpoints Beyond the Generated CRUD
Brandon Perfetti

Technical PM + Software Engineer

Topics:Custom APIsStrapi v4Routes
Tech:Node.jsKoaJavaScript

Strapi v4 ships fast with auto-generated CRUD endpoints for content types, but real-world systems often need custom behavior: composite queries, transactional operations, orchestration with external services, or non-standard routes (webhooks, file transforms, analytics). This article shows how to extend Strapi beyond the generated APIs by adding custom routes, controllers, policies, and middleware. The examples are implementation-forward and assume you have a working Strapi v4 project and familiarity with JavaScript and Koa concepts. We’ll cover file layout, route declarations, controller patterns, policy-based access control, middleware integration, and practical tips for testing and deployment.

1) When and why to build custom endpoints

Start with the generated controllers for simple CRUD, but build custom endpoints when you need: composite operations against multiple content types, transactional flows, custom input validation beyond schemas, optimized database queries, batch processing, or endpoints that don’t map to a single content-type (e.g., search, analytics, exports). Custom endpoints also let you enforce fine-grained authorization and insert cross-cutting behavior (metrics, request shaping, caching).

Before coding, answer: what resource shape should the API expose, who calls it, what permissions apply, and whether you need to reuse Strapi services. Sketch the route paths, HTTP verbs, expected request/response payloads, and error handling strategy. This saves rework and keeps your Strapi layer focused as an API gateway.

  • Good candidates: aggregated read endpoints, transactional writes, webhook receivers, async job triggers, export endpoints
  • Keep generated controllers for simple CRUD; extend or wrap them rather than duplicating logic
  • Prefer Strapi services for reusable business logic

2) Adding routes: structure and examples

In Strapi v4, custom routes live under src/api/<api-name>/routes/*.js or src/extensions. Each route file exports an array of route objects. Use the route's config.property to attach policies or middleware. A minimal route example for a custom search endpoint:

Place the file: src/api/article/routes/custom-search.js and add:

  • Example route file content:
  • module.exports = [{
  • method: 'GET',
  • path: '/articles/search',
  • handler: 'article.customSearch',
  • config: { policies: [] }
  • }];
  • Key points: handler references api's controller method in the form 'controllerName.methodName' (controller files live at src/api/article/controllers)

3) Writing custom controllers

Controllers implement request/response logic and orchestrate services. Keep controllers thin: validate input, call services, return shaped responses, and handle errors consistently. Strapi controllers are straightforward objects exported by files like src/api/article/controllers/article.js.

Example controller that uses Strapi entityService and a custom service for search:

module.exports = {

async customSearch(ctx) {

const { q, page = 1, pageSize = 10 } = ctx.query;

if (!q) {

ctx.throw(400, 'query parameter q is required');

}

// call a service that builds an optimized query or hits an external search index

const results = await strapi.service('api::article.search').search({ q, page, pageSize });

ctx.body = { data: results };

}

};

If you need transactional writes spanning multiple models, use Strapi's query API with the same entity manager instance when available. For complex operations move logic into services to keep controllers testable.

  • Controller patterns: validate -> call services -> shape response -> handle errors
  • Prefer ctx.throw for HTTP errors (Strapi will format them)
  • Use strapi.entityService or strapi.db.query for direct data access

4) Policies: enforcing access and preconditions

Policies are middleware-like functions that run before controllers. They’re ideal for role-based access, workspace scopes, rate limiting, or request guards. Policies live in src/policies and are referenced in route config. A policy receives ctx and next; call next() to proceed or throw to abort.

Example policy to ensure an article's author matches the logged-in user for edit operations:

module.exports = async (ctx, next) => {

const user = ctx.state.user;

if (!user) ctx.unauthorized('Authentication required');

const id = ctx.params.id;

const article = await strapi.entityService.findOne('api::article.article', id, { populate: ['author'] });

if (!article) return ctx.notFound('Article not found');

if (article.author?.id !== user.id && !user.role?.name === 'Administrator') {

return ctx.forbidden('You are not the author');

}

await next();

};

Attach it to a route: config: { policies: ['global::isAuthorOrAdmin'] } where 'global::' resolves to src/policies/isAuthorOrAdmin.js

  • Policies run before controllers; they can short-circuit requests
  • Use policies for authorization, quotas, and input sanity checks
  • Load policies in route config via 'global::policyName' or 'plugin::pluginName.policyName'

5) Middleware: cross-cutting concerns

Middleware in Strapi hooks into every request or selected routes to implement logging, metrics, request transformation, or authentication adapters. Define custom middleware in src/middlewares and register it in config/middlewares.js (or server.js in older projects). Middleware functions follow Koa style: (ctx, next) => Promise.

Example request timing middleware:

module.exports = () => {

return async (ctx, next) => {

const start = Date.now();

await next();

const ms = Date.now() - start;

strapi.log.info(`${ctx.method} ${ctx.url} - ${ms}ms`);

};

};

To enable it, add an entry in config/middlewares.js:

module.exports = [

'strapi::errors',

{ name: 'global::request-timer' },

// other middlewares

];

Use middleware for features that apply across endpoints; prefer policies for per-route authorization logic.

  • Middleware is global by default; route-level middleware is possible via route config
  • Use middleware for logging, metrics, input normalization, or tracing
  • Avoid heavy synchronous work in middleware to prevent request blocking

6) Testing, validation, and deployment considerations

Custom endpoints must be tested and monitored. Unit-test controllers and services by mocking strapi.* globals. For integration tests, run Strapi in a test database (SQLite in-memory) and use supertest or HTTP clients to exercise endpoints. Validate inputs using libraries like Joi or Zod inside controllers or services before persisting.

CORS, rate-limiting, and input size limits are often overlooked. Configure them either in Strapi settings or via middleware. When deploying to multiple instances, ensure stateless controllers; move long-running jobs to background workers (e.g., BullMQ) instead of blocking request flows.

Security checklist: validate all input, escape outputs if rendering HTML, centralize auth in policies, and avoid leaking internal errors. For performance, profile DB queries: use strapi.db.query with select/populate/limit to avoid N+1 issues and consider custom SQL queries for heavy aggregations.

  • Unit test controllers by mocking strapi.service and strapi.entityService
  • Use integration tests with a disposable DB to validate end-to-end behavior
  • Move heavy processing to background jobs and return 202 Accepted when appropriate
  • Monitor endpoints with metrics and alert on error rates or latency spikes

Conclusion

Extending Strapi v4 with custom routes, controllers, policies, and middleware gives you the flexibility required for production-grade APIs without abandoning the admin and content modeling benefits. Follow the patterns: keep controllers thin and focused, centralize business logic in services, use policies for authorization, middleware for cross-cutting concerns, and test thoroughly. Start small—add one custom route, wire a policy and a service—and iterate as you identify needs.

Action Checklist

  1. Identify a single composite operation in your app that the default CRUD doesn't handle and implement it following this article's structure: route -> controller -> service -> policy
  2. Move shared logic into src/services to keep controllers testable and maintainable
  3. Write unit and integration tests for your new endpoints; mock strapi internals in unit tests
  4. Add monitoring middleware and deploy behind a load balancer, ensuring background jobs handle long tasks