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 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:
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 });
const articles = await articlesService.readByQuery({
limit: -1,
fields: ['id', 'status', 'date_published', 'author'],
sort: ['-date_published'],
filter: { status: { _eq: 'published' } }
});
// Server-side aggregation
const summary = {
totalPublished: articles.length,
recentAuthors: [...new Set(articles.map(a => a.author))].slice(0, 5),
generatedAt: new Date().toISOString()
};
// Explicit cache headers for frontend consumption
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
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:
flowchart TD
Req["Frontend GET /analytics/content-summary"] --> Auth{"accountability present and admin?"}
Auth -->|No| Deny["403 Insufficient permissions"]
Auth -->|Yes| Svc["ItemsService (scoped by role)"]
Svc --> Query["readByQuery: published articles"]
Query --> Agg["Server-side aggregation (flat JSON)"]
Agg --> Headers["Set Cache-Control headers"]
Headers --> Resp["200 JSON summary"]
Key architectural decisions in this pattern:
- Service Instantiation: Using
services.ItemsServiceautomatically 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
envobject 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.
Optimizing for Frontend Consumption & State Management
Custom endpoints don’t inherit Directus’s built-in cache headers, so set Cache-Control or ETag explicitly based on how volatile the aggregated data is. 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 compiles extensions at startup, so a syntax error or missing dependency halts the whole application — run directus build in staging before promoting. 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.
Conclusion
Custom API extensions move data orchestration to the server: smaller bundles, fewer round-trips, access control enforced server-side. Instantiate ItemsService instead of raw Knex, set CORS explicitly, and return flat deterministic schemas, and the integration holds up as the app grows.