Directus Custom Extensions Development
Directus extends via Extensions — TypeScript/JavaScript modules in the extensions/ directory. Extension types: Hooks (events), Endpoints (routes), Operations (Flows steps), Interfaces (admin UI fields), Displays, Panels, Modules, Layouts, Bundles.
Extensions Structure
extensions/
├── hooks/
│ └── sync-to-crm/
│ ├── index.ts
│ └── package.json
├── endpoints/
│ └── custom-api/
│ ├── index.ts
│ └── package.json
├── operations/
│ └── send-sms/
│ ├── index.ts # server part
│ ├── app.ts # Flow builder UI
│ └── package.json
└── interfaces/
└── color-picker/
├── index.ts # Vue component
└── package.json
Hook Extension
// extensions/hooks/lifecycle-events/index.ts
import type { HookExtensionContext } from '@directus/types'
export default ({ action, filter, schedule, init }: HookExtensionContext) => {
// Event on record create
action('items.create', async ({ collection, item, accountability }) => {
if (collection === 'orders') {
console.log(`New order created: ${item.id}`)
// Send to CRM
await fetch(process.env.CRM_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'order_created',
orderId: item.id,
customer: item.customer_email,
total: item.total,
}),
})
}
})
// Event on update
action('items.update', async ({ collection, keys, payload }) => {
if (collection === 'articles' && payload.status === 'published') {
for (const id of keys) {
// Invalidate Next.js cache
await fetch(`${process.env.NEXTJS_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-revalidate-secret': process.env.REVALIDATE_SECRET!,
},
body: JSON.stringify({ tag: 'articles', id }),
})
}
}
})
// Filter — change data before save
filter('items.create', (payload, { collection }) => {
if (collection === 'articles' && !payload.slug) {
payload.slug = (payload.title as string)
?.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '')
}
return payload
})
// Cron job
schedule('0 8 * * *', async () => {
console.log('Daily digest job running...')
// Send digest to subscribers
})
// Initialization — executes on startup
init('app.before', async ({ app }) => {
console.log('Directus starting up...')
})
}
Endpoint Extension
// extensions/endpoints/reports/index.ts
import type { EndpointExtensionContext } from '@directus/types'
import { Router } from 'express'
export default (router: Router, { services, getSchema }: EndpointExtensionContext) => {
// GET /reports/sales
router.get('/sales', async (req, res) => {
const schema = await getSchema()
const { ItemsService } = services
// Check authentication
if (!req.accountability?.role) {
return res.status(403).json({ error: 'Unauthorized' })
}
const ordersService = new ItemsService('orders', { schema, accountability: req.accountability })
const orders = await ordersService.readByQuery({
filter: { status: { _eq: 'completed' } },
fields: ['id', 'total', 'date_created', 'customer'],
limit: -1,
})
const totalRevenue = orders.reduce((sum: number, o: any) => sum + (o.total || 0), 0)
const avgOrder = orders.length > 0 ? totalRevenue / orders.length : 0
return res.json({
totalOrders: orders.length,
totalRevenue,
avgOrder: Math.round(avgOrder * 100) / 100,
})
})
// POST /reports/export
router.post('/export', async (req, res) => {
const { collection, format = 'csv', filters } = req.body
const schema = await getSchema()
const { ItemsService } = services
const service = new ItemsService(collection, { schema, accountability: req.accountability })
const items = await service.readByQuery({ filter: filters, limit: -1 })
if (format === 'csv') {
const headers = Object.keys(items[0] || {})
const csv = [
headers.join(','),
...items.map((item: any) => headers.map(h => JSON.stringify(item[h] ?? '')).join(','))
].join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="${collection}.csv"`)
return res.send(csv)
}
return res.json({ data: items })
})
}
Operation Extension (for Flows)
// extensions/operations/send-sms/index.ts (server part)
import type { OperationExtensionContext } from '@directus/types'
export default {
id: 'send-sms',
handler: async ({ phone, message }: { phone: string; message: string }, context: OperationExtensionContext) => {
if (!phone || !message) {
throw new Error('Phone and message are required')
}
const response = await fetch('https://api.sms-provider.com/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: phone, text: message }),
})
const result = await response.json()
if (!result.success) {
throw new Error(`SMS failed: ${result.error}`)
}
return { sent: true, messageId: result.id }
},
}
// extensions/operations/send-sms/app.ts (Flow builder UI)
export default {
id: 'send-sms',
name: 'Send SMS',
icon: 'sms',
description: 'Send SMS via provider',
overview: ({ phone, message }: any) => [
{ label: 'Phone', text: phone },
{ label: 'Message', text: message },
],
options: [
{
field: 'phone',
name: 'Phone Number',
type: 'string',
meta: { interface: 'input', required: true },
},
{
field: 'message',
name: 'Message',
type: 'text',
meta: { interface: 'input-multiline', required: true },
},
],
}
Bundle — Multiple Extensions in One Package
// extensions/my-bundle/index.ts
import type { BundleExtension } from '@directus/types'
const bundle: BundleExtension = {
hooks: [lifecycleHook],
endpoints: [reportsEndpoint],
operations: [{ api: sendSmsOperation, app: sendSmsApp }],
}
export default bundle
Building Extensions
// extensions/hooks/sync-to-crm/package.json
{
"name": "directus-extension-sync-to-crm",
"version": "1.0.0",
"directus:extension": { "type": "hook", "path": "dist/index.js", "source": "src/index.ts" },
"scripts": { "build": "directus-extension build" },
"devDependencies": { "@directus/extensions-sdk": "^10.0.0" }
}
cd extensions/hooks/sync-to-crm
npm install
npm run build
# Restart Directus
Timeline
Developing a set of extensions (Hook + Endpoint + Operation) — 3–5 days.







