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:Strapi v4Custom APIsBackend
Tech:Node.jsKoa

Strapi v4 Custom API: Building Endpoints Beyond the Generated CRUD

Generated CRUD endpoints in Strapi v4 are incredibly useful for bootstrapping a product, but as soon as you need domain workflows, cross-entity aggregates, or guaranteed side effects, the default handlers become a liability. This guide gives an actionable blueprint for designing, implementing, and operating custom Strapi endpoints that remain maintainable under production pressure. You'll get concrete code patterns, tradeoffs, common pitfalls, and decision criteria for every major layer: routes, controllers, services, policies, middleware, validation, observability, and testing.

Throughout the examples I assume Strapi v4, Node.js and Koa (ctx), and that you're working in src/api/<name> files. Where Strapi APIs vary by database (SQL vs NoSQL) I call that out explicitly.

When Generated CRUD Stops Being Enough (Decision Criteria)

Generated controllers are great for simple create/read/update/delete on a single collection type. But you should reach for custom endpoints when:

  • The operation represents a domain action (submit order, publish release, archive project) rather than a simple resource mutation.
  • You must coordinate multiple content types or external systems atomically or idempotently.
  • You need deterministic authorization rules beyond role flags (ownership, tenant scoping, workflow stage).
  • You require composite responses (aggregates, joins, normalized shapes) that are cached or paginated differently.
  • You must attach side effects (email, webhook, analytics) that need retry, queueing, or audit trails.

If your endpoint is more than “CRUD over one row,” prefer a custom route and service. Decision criteria: clarity of intent, atomicity needs, cross-entity coupling, side-effect guarantees, and API stability expectations.

Architecture Principle: Keep Domain Logic Out of Controllers

A pragmatic layering prevents accidental complexity. I recommend this separation:

  • Route: defines HTTP contract (method/path) and binds policies.
  • Policy: short, deterministic authorization/guard checks.
  • Controller: thin orchestration — parse/normalize input, call service, map response, handle HTTP concerns.
  • Service: the meat — domain rules, cross-entity coordination, transaction boundaries, side-effect orchestration.
  • Middleware: cross-cutting concerns (request ids, logging, rate limits).

Why? Controllers belong to the HTTP layer; services belong to the domain. When you re-use domain logic (cron tasks, admin scripts, queues), services are the only maintainable place.

Pitfall: scattering authorization across controllers and services leads to subtle bypass bugs. Always implement the gate as a policy when it’s about “may this caller” and keep services defensively checking invariants (not authorization).

Custom Routes: Explicit Contracts First

Custom routes provide intent-driven endpoints and explicit contracts. Place them under src/api/<api>/routes/*.js. Example route for an order submission:

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

Tradeoffs: Using explicit routes gives clarity at the cost of more route surface area to document. Establish a naming convention early: verbs for domain actions (POST /orders/:id/submit), nouns for resources (GET /orders/:id). Decision rule: if an API call does an intentful action or coordinates multiple resources — make it a custom route.

Pitfall: overloading resource endpoints with query flags (e.g., PATCH /order/:id?complete=true) hides intent and complicates metrics/telemetry. Prefer dedicated paths.

Controller Design: Thin, Testable, and Predictable

Controller responsibilities should be limited: validate/normalize HTTP input, call the service, and map the result to HTTP. Keep consistent error and success envelope shapes.

Example controller using Koa ctx and Joi for validation:

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

    // caller identity injected by authentication middleware/policy
    const user = ctx.state.user;

    const result = await orderService.submitOrder(id, user, { metadata: value.metadata });

    ctx.body = { data: result };
  },
};

Tradeoffs: Keeping controllers thin enforces testability and reuse. The controller should not call strapi.db.query directly — that belongs in services. Pitfall: putting side effects (email/send webhook) in controllers makes it hard to reuse the action from non-HTTP contexts.

Decision criteria: if code needs to be shared by a cron job or admin UI, it belongs in a service.

Services: The Domain Workhorse (Transactions, Side Effects, Idempotency)

Services implement domain logic with clear transactional boundaries and side-effect orchestration. They should return domain result objects, not HTTP responses.

Example service with transactional writes, business rules, and backgrounding of slow work. Note: transaction support differs by connector. For SQL connectors you can use the Knex connection; for NoSQL you’ll need compensating sequences or two-phase commits via external stores.

// src/api/order/services/order.js
module.exports = {
  // idempotent by design: repeated submissions for same order state are safe
  async submitOrder(orderId, user, { metadata, trx } = {}) {
    const order = await strapi.entityService.findOne("api::order.order", orderId, {
      populate: ["items", "customer"],
      // optional ctx or transaction passed from controller when supported
    });

    if (!order) throw new Error("ORDER_NOT_FOUND");

    // Domain rule: only 'draft' orders can be submitted
    if (order.status !== "draft") throw new Error("INVALID_STATE");

    // Example transactional update for SQL backends
    if (!trx && strapi.db?.connection?.transaction) {
      const knex = strapi.db.connection;
      await knex.transaction(async (knexTrx) => {
        return module.exports._performSubmission(orderId, user, { metadata, trx: knexTrx });
      });
    } else {
      // either trx supplied (test) or non-SQL connector: perform sequenced operations
      await module.exports._performSubmission(orderId, user, { metadata, trx });
    }

    // Return fresh order snapshot
    return strapi.entityService.findOne("api::order.order", orderId, { populate: ["items", "customer"] });
  },

  async _performSubmission(orderId, user, { metadata, trx } = {}) {
    // update order status
    await strapi.entityService.update("api::order.order", orderId, {
      data: { status: "submitted", submittedAt: new Date(), metadata },
      // Some connectors accept { transacting: trx }
    });

    // side effect: create audit record (synchronous write, cheap)
    await strapi.entityService.create("api::audit.log", {
      data: { action: "submit_order", resource: orderId, user: user.id, metadata },
    });

    // slow side effect: webhook/email — don't await inline, push to
 queue
    strapi.plugin("email-queue")?.services.queue.add("sendOrderSubmitted", { orderId, user: user.id })
      .catch((err) => strapi.log.error("Failed to enqueue email", err));
  },
};

Tradeoffs and pitfalls:

  • Synchronous side effects (external HTTP calls) inside transactions are risky — they can block DB commits and cause timeouts. Move slow work to a queue.
  • For NoSQL connectors without transactions, build idempotency and compensating actions. For example, record a submission token before external side effects and use it to dedupe retries.
  • Services should not assume HTTP context (no ctx access). Provide parameters and return plain objects.

Decision criteria for transactions:

  • If multiple DB writes must be atomic and your connector supports transactions → use DB transactions.
  • If external calls are involved → make side effects async/queued and make your operation idempotent.

Policies: Authorization Beyond Roles

Strapi roles are coarse. Policies let you encode nuanced permissions: ownership checks, tenant scoping, feature flags, or workflow-stage constraints. Put lightweight, deterministic checks in policies and use services for domain invariants.

Example ownership policy:

// 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");

  // Assuming order.customer is the user id (adjust for relation shape)
  if (order.customer !== user.id) return ctx.forbidden("Not owner of this order");

  await next();
};

Tradeoffs:

  • Keep policies cheap. If a policy needs heavy DB joins, cache the check result on ctx.state earlier in middleware or aggregate checks to avoid repeated lookups.
  • Return clear denial reasons in logs/metrics but avoid leaking sensitive data to clients.

Decision criteria:

  • Use policies for "can this caller perform X?" logic.
  • Use service-level checks for invariants like “order must be in draft state” (domain, not authorization).

Pitfall: duplicating permission logic in controllers and services leads to drift and security gaps.

Middleware & Observability: Cross-Cutting Reliability

Use middleware to enforce cross-cutting concerns: request IDs, structured logging, rate limiting hooks, and global input sanitization.

Example simple request ID and error envelope middleware:

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

// src/middlewares/error-envelope.js
module.exports = () => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (err) {
      const safe = { code: err.code || "INTERNAL_ERROR", message: err.expose ? err.message : "Internal error" };
      strapi.log.error({ requestId: ctx.state?.requestId, err });
      ctx.status = err.status || 500;
      ctx.body = { error: safe };
    }
  };
};

Tradeoffs:

  • Middleware order matters. Put request-id before logging or any policy that needs consistent IDs.
  • Avoid putting heavy work in global middleware (like DB queries per request); use controller-level logic or cached context.

Decision criteria:

  • Use middleware for things that truly must apply to every inbound HTTP request (correlation IDs, parsing, global rate limits).

Validation & Error Handling: Fail Fast, Consistent Contracts

Design and document a single error envelope shape across your APIs: code, message, details. Validate query params, path params, and body at the controller layer (or use a shared validation library/service) before calling services. That prevents partial side effects.

Example validation pattern using Joi (shown earlier) — return 400 with consistent detail array. Map domain errors to HTTP status codes in a central error mapper. Define a small taxonomy: validation (400), permission (403), not found (404), conflict (409), dependency failure (502), server error (500).

Pitfalls:

  • Throwing raw Error() objects without codes will produce inconsistent client responses.
  • Logging stack traces to the client leaks secrets — log them internally only.

Decision criteria:

  • Validate everything that can cause partial state mutation before calling services.
  • Centralize mapping from domain error codes to HTTP statuses, and keep user messages generic.

Performance, Query Patterns, and Caching

Custom endpoints commonly become aggregation or join-heavy hotspots. Avoid N+1 reads by using populate with explicit fields, writing optimized queries, and leveraging Strapi queries directly when you need SQL features.

Guidelines:

  • Project only required fields. Excessive populate affects latency and memory on large payloads.
  • For hot read paths, consider a read-side cache (Redis) keyed by meaningful input and include invalidation rules when underlying data changes.
  • Use pagination for any collection response that could grow.
  • Measure p95 latency per endpoint, not just averages.

Concrete example: if you build a dashboard summary that reads order counts and recent revenue, compute aggregates in a single optimized query (or a materialized view / precomputed daily metric) instead of fetching orders and summarizing in JavaScript.

Tradeoffs:

  • Caching improves latency but adds complexity for invalidation. Use TTLs for eventually-consistent metrics and explicit invalidation hooks for critical financial data.
  • Using low-level DB queries gives performance but sacrifices connector agnosticism and portability. Choose based on how coupled you are to your DB.

Testing, Versioning, and Deployment Patterns

Comprehensive testing keeps custom endpoints maintainable.

Testing pyramid:

  • Unit tests for policies and services (mock strapi.entityService or use a test DB).
  • Integration tests that spin up Strapi or use a lightweight fixture to validate full request flow (route → policy → controller → service).
  • Contract tests for response shape and error envelope.

Key tests to write: unauthorized attempts, invalid payloads, conflicting state transitions, idempotency on retries, and external side-effect queuing.

Versioning and rollout:

  • For breaking changes, prefer URI versioning (/v1/orders/:id/submit → /v2/...) or header versioning for smaller surface areas.
  • Use feature flags when shipping risky endpoints; route traffic with a proxy or a flag check at policy level.
  • Deploy behind a staging environment and run smoke tests that exercise the full external integration surface (emails, webhooks).

Pitfalls:

  • Relying on manual QA for state transitions often misses race conditions. Simulate concurrent requests in tests.
  • Adding new fields to responses without versioning can still break clients if they depend on strict schemas — document additive changes and maintain backward-compatible fields.

Practical Example: Build an Order Submit Endpoint (Checklist)

  1. Define contract: POST /orders/:id/submit, body { confirm: boolean, metadata?: object }, response { data: { order } } and possible errors (400,403,404,409,500).
  2. Create policies: is-authenticated, is-order-owner.
  3. Implement controller for validation and mapping (Joi).
  4. Implement service for transactional state transition, audit record, and queueing side effects.
  5. Add middleware: request id, error envelope, structured logging.
  6. Add integration tests: happy path, duplicate submit, unauthorized, invalid payload, and simulated external failure.
  7. Add monitoring: Prometheus counter for submit attempts, histogram for latency, and a dashboard for p95 and error rate.
  8. Deploy behind a feature flag; run canary traffic and validate metrics before full rollout.

This checklist turns a risky, multi-step operation into a predictable contract you can iterate on.

Final Tradeoffs and Tells

  • When to extend generated CRUD: if you can cleanly express behavior as a resource mutation with the same semantics and no cross-entity coordination, generated controllers are fine. If not, split out.
  • Transactions vs. eventual consistency: use transactions for small multi-write atomicity when supported. Prefer eventual consistency + idempotent operations when you must talk to external services.
  • Policies vs service checks: policies for caller capability; services for domain correctness. Duplicate checks only when necessary for defense-in-depth.
  • Observability: instrument every custom endpoint. High-value business flows deserve route-level SLIs and alerts.

Custom endpoints are the locus where business intent meets infrastructure. When you treat them as first-class contracts — with clear validation, thin controllers, robust services, deterministic policies, and consistent observability — they scale from prototype to production without producing a maintenance tax.

References and further reading:

  • Strapi backend customization docs (routes/controllers/services/policies/middlewares)
  • Choose a validation library (Joi/Zod) and stick with it across controllers
  • Instrumentation: Prometheus + Grafana or OpenTelemetry for distributed traces

If you want, I can scaffold a complete example repo for the order submit flow (routes, controller, service, policy, middleware, and tests) tailored to your database connector (Postgres vs Mongo).