Strapi v4 Custom API: Building Endpoints Beyond the Generated CRUD

Strapi’s generated APIs are good right up until the moment your product starts behaving like an actual product.
At first, the default CRUD layer feels like a gift. You define a content type, Strapi gives you endpoints, and you keep moving. For a while, that is exactly the right trade.
Then the requirements change.
Now you need an endpoint that submits an order, not just updates a row. Or a workflow that coordinates multiple content types. Or authorization rules that depend on ownership, tenant context, or stage of approval. Or side effects that have to happen safely when the main mutation succeeds.
That is where the generated endpoints stop feeling like productivity and start feeling like a ceiling.
This is the point where custom APIs in Strapi v4 become worth understanding properly.
The moment generated CRUD stops being enough
The easiest way to decide whether you need a custom endpoint is to ask a simple question:
Is this request just manipulating a single resource, or is it expressing a domain action?
That distinction matters more than it seems.
Generated CRUD is great for things like:
- creating a new article
- listing categories
- updating a profile field
- deleting a simple record
It starts to get awkward when the operation becomes something like:
- submit an order
- approve a request
- archive a project
- publish a release
- build a composite dashboard response
- trigger external side effects after a successful domain change
Those are not really CRUD operations anymore. They are workflows.
Once an endpoint carries business intent, it usually deserves its own route, controller, and service.
In plain English: if the request means more than “change this row,” generated CRUD is probably no longer the right shape.
The cleanest architecture rule: keep domain logic out of controllers
This is the rule that saves the most pain later.
In Strapi, it is very easy to let controller files become the place where everything happens. Validation, business logic, permission checks, side effects, and database writes all end up stacked inside one handler because it works and the file is right there.
That approach feels fast once.
Then six months later it becomes hard to test, hard to reuse, and hard to trust.
A better separation looks like this:
- route: defines the HTTP contract
- policy: decides whether the caller is allowed
- controller: handles request parsing and response shaping
- service: runs the domain logic
- middleware: handles cross-cutting concerns like request IDs or logging
That separation is not academic. It gives you clearer ownership.
A controller should know about ctx. A service should know about the business rule. Those are not the same concern.
Custom routes are where intent becomes visible
One of the biggest advantages of custom APIs is that the route itself can tell the truth about what the action means.
Compare these two ideas:
PATCH /orders/:id?submitted=truePOST /orders/:id/submit
Both can change state. Only one clearly communicates that this is a workflow step.
That is why dedicated routes are often better once the endpoint becomes domain-specific.
In Strapi v4, a custom route definition is straightforward:
// src/api/order/routes/order.js
module.exports = {
routes: [
{
method: 'POST',
path: '/orders/:id/submit',
handler: 'order.submit',
config: {
policies: ['global::is-authenticated', 'global::is-order-owner'],
},
},
],
};
That is already better than hiding workflow semantics behind a generic update call.
The route becomes part of the contract, not just a transport detail.
Controllers should stay thin on purpose
A good Strapi controller usually does three things:
- normalize and validate input
- call a service
- map the service result into an HTTP response
That is enough.
Once a controller starts directly coordinating several queries, triggering emails, or deciding business state transitions, it is doing too much.
A practical example:
// src/api/order/controllers/order.js
const Joi = require('joi');
const orderService = require('../services/order');
const submitSchema = Joi.object({
confirm: Joi.boolean().truthy('yes').required(),
metadata: Joi.object().optional(),
});
module.exports = {
async submit(ctx) {
const { id } = ctx.params;
const { error, value } = submitSchema.validate(ctx.request.body, {
abortEarly: false,
});
if (error) {
return ctx.throw(400, {
message: 'Invalid payload',
details: error.details,
});
}
const result = await orderService.submitOrder(id, ctx.state.user, {
metadata: value.metadata,
});
ctx.body = { data: result };
},
};
That controller is readable because it stays disciplined.
It is not trying to be the entire system.
Services are where the real work belongs
This is where custom Strapi APIs become genuinely useful.
The service layer is where you can safely express things that generated CRUD never models well:
- state transitions
- multi-entity coordination
- idempotency rules
- audit logging
- transaction boundaries
- async side-effect orchestration
That is also why services are much easier to reuse later.
A cron job, admin action, queue worker, or CLI script can all call the same service logic if the logic is not trapped in a controller.
A service example looks more like this:
// src/api/order/services/order.js
module.exports = {
async submitOrder(orderId, user, { metadata } = {}) {
const order = await strapi.entityService.findOne('api::order.order', orderId, {
populate: ['items', 'customer'],
});
if (!order) {
throw new Error('ORDER_NOT_FOUND');
}
if (order.status !== 'draft') {
throw new Error('INVALID_STATE');
}
await strapi.entityService.update('api::order.order', orderId, {
data: {
status: 'submitted',
submittedAt: new Date(),
metadata,
},
});
await strapi.entityService.create('api::audit.log', {
data: {
action: 'submit_order',
resource: orderId,
user: user.id,
metadata,
},
});
return strapi.entityService.findOne('api::order.order', orderId, {
populate: ['items', 'customer'],
});
},
};
Even in a simplified example, you can see the difference.
The service owns the workflow. The controller just exposes it.
Policies are for authorization, not for business rules
This distinction matters a lot.
A policy should answer a question like:
Can this caller do this?
It should not be responsible for answering:
Should this entity be in this state?
Those are different problems.
For example, an ownership policy is a good policy use case:
// src/policies/is-order-owner.js
module.exports = async (ctx, next) => {
const user = ctx.state.user;
const orderId = ctx.params.id;
if (!user) return ctx.unauthorized('Authentication required');
const order = await strapi.entityService.findOne('api::order.order', orderId, {
fields: ['id', 'customer'],
});
if (!order) return ctx.notFound('Order not found');
if (order.customer !== user.id) return ctx.forbidden('Not owner of this order');
await next();
};
That is a permission question.
By contrast, “an order must be in draft state before submission” is a domain rule. That belongs in the service.
Keeping those responsibilities separate makes failures easier to understand and prevents authorization logic from getting mixed into the workflow itself.
Middleware should handle the stuff every endpoint needs
Middleware is where I like to put the boring but necessary concerns that should be consistent everywhere:
- request IDs
- structured logging
- response timing
- error envelopes
- global sanitization or rate-limit hooks
This is especially helpful once your custom endpoints start mattering operationally.
A lightweight request ID middleware can already make production debugging much easier:
// src/middlewares/request-id.js
module.exports = () => {
return async (ctx, next) => {
ctx.state.requestId =
ctx.get('x-request-id') || require('crypto').randomUUID();
ctx.set('X-Request-Id', ctx.state.requestId);
const start = Date.now();
try {
await next();
} finally {
const duration = Date.now() - start;
strapi.log.info({
requestId: ctx.state.requestId,
method: ctx.method,
path: ctx.path,
duration,
});
}
};
};
None of that is glamorous. It just pays off the first time an endpoint starts misbehaving and you need to trace what actually happened.
Validation should happen before side effects are even possible
This sounds obvious, but it is one of the most common ways custom endpoints become fragile.
If a request can trigger a partial state change before input is fully validated, you have already made the workflow harder to reason about.
That is why I prefer validating:
- path params
- query params
- body input
- required context
before the service is allowed to mutate anything.
The point is not to worship validation libraries. It is to keep the mutation path honest.
Once the service starts, it should be dealing with valid inputs and clear domain decisions, not still sorting through malformed request state.
Side effects are where generated CRUD really runs out of road
This is the part many teams eventually hit.
A custom endpoint might need to:
- update a Strapi entity
- create an audit record
- enqueue an email
- fire a webhook
- emit analytics
- notify another service
That is not a normal single-record mutation anymore.
And it introduces a real design question:
Which of those things need to happen synchronously, and which should be queued?
The safest default is usually:
- keep authoritative database state changes close together
- avoid doing slow external work inline if you can queue it
- make the operation idempotent if retries are possible
This is where service design matters a lot more than whether you used the generated CRUD layer originally.
Performance and query shape matter more once you leave CRUD land
As soon as you start building composite endpoints, read models, or aggregate responses, performance becomes part of the API design itself.
This is the point where teams often discover that “just populate everything” is not a strategy.
Custom endpoints tend to need more intention around:
- field selection
- relationship loading
- aggregate computation
- pagination
- caching
- latency measurement
If a custom endpoint becomes hot, it often deserves a more deliberate query shape than the generic layer would ever give you.
That does not mean you should immediately drop to raw SQL or database-specific tricks. It means you should stop assuming the generic path is automatically optimal just because it was fast to scaffold.
Testing custom Strapi APIs is worth the extra effort
Generated CRUD is nice partly because the behavior is predictable.
Once you add custom workflows, the behavior becomes your responsibility.
That means tests are not optional ceremony anymore. They are the thing standing between “this seems right” and “we can trust this under real use.”
The most valuable tests are usually:
- unauthorized access
- invalid payloads
- invalid state transitions
- duplicate or repeated submissions
- successful workflow completion
- external failure handling
This is especially important once an endpoint has business meaning.
No one wants to discover in production that “submit order” is not idempotent or that a policy check only works for one role path.
When custom APIs are worth the extra complexity
The answer is not “always once things get serious.”
There is still a cost.
A custom API usually means:
- more code to own
- more contracts to document
- more testing responsibility
- more room for architectural drift if the layering gets sloppy
So the right threshold is not whether custom APIs feel powerful. It is whether the product behavior actually needs them.
If the endpoint is still just straightforward record CRUD, generated Strapi APIs are often exactly the right tool.
If the endpoint is expressing workflow, coordination, business rules, or side effects, that is when the extra structure starts paying you back.
Final takeaway
Strapi’s generated CRUD layer is excellent scaffolding. It is just not the final architecture for every backend.
Once your API starts needing intent, orchestration, and stronger guarantees, the custom route and service layer is usually the healthier place to be.
The practical pattern is pretty simple:
- make routes explicit
- keep controllers thin
- move domain logic into services
- use policies for authorization
- use middleware for cross-cutting concerns
- validate early
- queue slow side effects
- test the workflows that actually matter
Do that, and your custom Strapi APIs stop feeling like an escape hatch from CRUD and start feeling like what they really are: the place where your backend finally begins to speak the language of your product.