Directus Custom API Extensions for Frontend Apps

Directus custom API extensions move data transformation, payload aggregation, and third-party orchestration off the client and into permission-aware endpoints inside the Directus runtime. That shrinks network payloads and keeps access control on the server. This guide covers the three failures that break these extensions in production — module resolution, permission scoping, and CORS — plus a secure defineEndpoint pattern and deployment workflow. It’s part of Directus Data Layer Patterns, within Platform Integration Deep Dives.

Three Failures That Break Custom Endpoints

Module resolution. Extensions run in an isolated Node environment. Import an external package without declaring it in the extension’s package.json or bundling it, and the runtime throws ERR_MODULE_NOT_FOUND — usually surfacing as an opaque 500 in production logs. Build with the Directus CLI bundler, or mark dependencies external to avoid tree-shaking conflicts.

Permission scoping. Custom endpoints that hit the database via raw SQL or an unscoped Knex instance bypass the access middleware that frontend requests expect to enforce role-based filtering. The result is over-exposed data, or 403 Forbidden when the query touches a collection the requesting role can’t read. Designing secure Directus Data Layer Patterns starts with respecting that scoping.

CORS. Directus restricts cross-origin requests until configured via environment variables. Missing Authorization forwarding or malformed Origin validation kills the preflight OPTIONS before your endpoint runs — and it only shows up once the frontend deploys to a separate subdomain. See MDN’s CORS reference for header configurations.

Step-by-Step Implementation: Secure Endpoint Exposure

Directus v10+ registers custom routes through defineEndpoint, which wires in middleware, request parsing, and response formatting. Create extensions/api/aggregate-analytics/index.js. This endpoint aggregates content metrics, respects user permissions, and returns a flat JSON shape:

JavaScript
import { defineEndpoint } from '@directus/extensions-sdk';

export default defineEndpoint((router, context) => {
  const { accountability, env, schema, services } = context;

  router.get('/analytics/content-summary', async (req, res) => {
    try {
      // Validate authentication context
      if (!accountability || accountability.admin === false) {
        return res.status(403).json({ error: 'Insufficient permissions' });
      }

      // Use Directus services instead of raw Knex to respect field-level permissions
      const ItemsService = services.ItemsService;
      const articlesService = new ItemsService('articles', { accountability, schema });
      
      // Count in the database instead of loading every row into memory.
      const [{ count }] = await articlesService.readByQuery({
        aggregate: { count: ['id'] },
        filter: { status: { _eq: 'published' } },
      });
      const recent = await articlesService.readByQuery({
        fields: ['author'],
        filter: { status: { _eq: 'published' } },
        sort: ['-date_published'],
        limit: 20,
      });

      const summary = {
        totalPublished: Number(count.id),
        recentAuthors: [...new Set(recent.map((a) => a.author))].slice(0, 5),
        generatedAt: new Date().toISOString(),
      };

      // Admin-only data must never be stored in shared caches.
      res.set('Cache-Control', 'private, no-store');
      return res.status(200).json(summary);
    } catch (error) {
      console.error('Analytics endpoint error:', error);
      return res.status(500).json({ error: 'Internal server error' });
    }
  });
});

A request through this endpoint passes accountability and permission checks before any data leaves the runtime:

A request through the extensionA frontend request reaches the endpoint, which checks accountability and rejects unauthorized callers with 403; authorized requests use ItemsService scoped by the caller's role, aggregate in the database and return flat JSON with cache headers appropriate to the data's audience.FrontendrequestAccountabilityallowed?403ItemsServicerole-scopedAggregatein databaseFlat JSON+ cache policynoyes
Permissions are enforced before any data is read, and caching follows who may see the result.

Key architectural decisions in this pattern:

  • Service Instantiation: Using services.ItemsService automatically applies Directus’s access control, field transformations, and relational fetching. Raw database queries should only be used when bypassing permissions is explicitly required and documented.
  • Environment Isolation: The env object from the endpoint context safely exposes runtime configuration without leaking secrets to the client.
  • Deterministic Responses: Frontend applications thrive on predictable schemas. Returning a flat, typed JSON object prevents hydration mismatches in frameworks like Next.js or Nuxt.
Extension types and when to use themDirectus extension types relevant to frontends, endpoints, hooks, operations and interfaces, with what each does and a typical use.ExtensionDoesTypical frontend useEndpointadds API routesaggregated or composed data for pagesHookruns on eventsvalidation, derived fields, notificationsOperationa step in flowssigned revalidation requestsInterfacecustom field UIslug generators, SEO previews
Endpoints serve frontends; hooks and operations react to events; interfaces help editors.

Optimizing for Frontend Consumption & State Management

Custom endpoints don’t inherit Directus’s built-in cache headers, so set Cache-Control explicitly, based on both how volatile the data is and who may see it: public only for data readable by anonymous users, private, no-store for anything that depends on the caller’s permissions. On the client, libraries like SWR or React Query handle background refetch and invalidation; if your frontend already runs Apollo or URQL, shape the payload to match so you skip a normalization step.

Validate incoming query parameters with Zod or Joi — unsanitized input invites injection and lets a caller force expensive queries. The OWASP API Security Top 10 covers the rest of the attack surface.

Deployment & Lifecycle Management

Directus loads extensions at startup, so a syntax error or missing dependency halts the whole application. Build extensions with npx directus-extension build in CI, load them in a staging instance, and promote only after it starts cleanly. In Docker, mount extensions/ as a volume or bake it into the image. For horizontal scaling behind NGINX or Traefik, use stateless JWT auth so sessions don’t drift across nodes. Version extension code alongside the frontend repo and pin each release to a compatible Directus core version so platform upgrades don’t break silently.

Validating Input and Limiting Cost

Every query parameter an endpoint accepts is an input an attacker controls. Parse parameters with a schema, rejecting anything unexpected, and cap values that drive cost, such as page sizes, date ranges and the number of ids in a batch. Endpoints that aggregate across large tables should set database query timeouts and, where results are shared by all users, cache them in Directus’s cache or at the CDN, so a burst of requests cannot turn into a burst of expensive queries. Rate-limit endpoints at the edge by client, and return consistent error shapes with appropriate status codes, 400 for invalid input, 403 for permission problems and 503 when a dependency is unavailable, so the frontend can react sensibly to each.

JavaScript
import { z } from 'zod';

const Query = z.object({
  category: z.string().regex(/^[a-z0-9-]+$/).optional(),
  page: z.coerce.number().int().min(1).max(50).default(1),
  size: z.coerce.number().int().min(1).max(48).default(24),
});

// in the route handler
const parsed = Query.safeParse(req.query);
if (!parsed.success) return res.status(400).json({ error: 'invalid query' });

Consuming Extensions from the Frontend

Treat an endpoint extension like any other API in the frontend’s data layer. Call it from the server with the appropriate token, the read-only token for public data or the user’s session for personalized data, and never from the browser with a privileged token. Validate the response with a runtime schema, since the extension’s output shape is a contract that can drift when someone edits the extension. For public endpoints, add cache tags that the revalidation flow can purge, such as the collections the endpoint reads from, so aggregated data updates when underlying items change. Document each endpoint’s contract, parameters, response shape, permissions and cache behaviour, next to its code, because extensions are easy to forget when the frontend changes.

Gotchas & Edge Cases

  • Knex bypasses permissions. context.database queries ignore access policies. Use services with the request’s accountability, or apply the same filters manually and document why.
  • Public caching of personalized data. Any response that depends on the caller’s role must be private. A single public header on the wrong endpoint leaks data through the CDN.
  • Unbounded reads. limit: -1 loads every row. Aggregate in the database or paginate.
  • Extension upgrades. The extensions SDK follows Directus releases. Pin versions and test extensions on every Directus upgrade.

Worked Example

A training provider built a Directus endpoint that returned course listings with availability computed from sessions and bookings, replacing five client-side requests per page. The first version used Knex directly and exposed unpublished courses to anonymous visitors; it also cached responses publicly for everyone, including editors’ draft views. The fixed version used ItemsService with the request’s accountability, returned public data with a five-minute public cache and anything else with no-store, and validated query parameters with Zod. Course pages dropped from five API requests to one, and unpublished courses no longer appeared anywhere public.

API requests per course pageRequests from the frontend to Directus per course page with client-side composition of several collections and with one aggregated endpoint extension.Client-side composition5 requestsEndpoint extension1 requests
Composition moved to the server, next to the data.

Rollout Checklist

  • Use defineEndpoint with services scoped by the caller’s accountability.
  • Aggregate and paginate in the database, never load whole tables.
  • Validate query parameters and cap limits.
  • Set public caching only for anonymous-readable data, otherwise private, no-store.
  • Configure CORS explicitly for the frontend’s origins.
  • Build extensions in CI and test them on each Directus upgrade.

Frequently Asked Questions

When should we write an extension instead of querying Directus from the frontend?

When a page needs data composed from several collections, computed values, or third-party calls with secrets. Simple reads are better served by the standard API and CDN caching.

Can extensions call external APIs?

Yes, with secrets from the environment. Cache responses and set timeouts so a slow third party does not block Directus or its other requests.

How do we test endpoint extensions?

Run them in a local Directus instance with a seeded database in CI, and call them with tokens of different roles to verify permissions.

Do extensions work with GraphQL?

Endpoint extensions add REST routes. For GraphQL, compose on the frontend or in a BFF, or add hooks that maintain computed fields readable through the standard API.

Where should extension code live?

In the same repository as the Directus configuration and schema snapshots, built and tested in CI, and deployed together with the instance image or volume.