Developing Custom Hooks (Extensions) in Directus
Hook Extension in Directus is a TypeScript function that subscribes to lifecycle events: creating, updating, deleting records, authentication, API requests. Used for side effects, validation, integrations with external systems.
Types of Events
// Hook types:
action('items.create', handler) // after creation
action('items.update', handler) // after update
action('items.delete', handler) // after deletion
filter('items.create', handler) // before creation (can modify data)
filter('items.update', handler) // before update
action('auth.login', handler) // after successful login
action('auth.logout', handler) // after logout
action('files.upload', handler) // after file upload
schedule('0 * * * *', handler) // cron
init('app.before', handler) // on server start
Full Hook Extension Example
// extensions/hooks/business-logic/index.ts
import type { HookExtensionContext } from '@directus/types'
import type { EventContext } from '@directus/types'
export default ({ action, filter, schedule }: HookExtensionContext) => {
// ===== AUTO SLUG GENERATION =====
filter('items.create', (payload, meta) => {
if (meta.collection === 'articles' && payload.title && !payload.slug) {
payload.slug = generateSlug(payload.title as string)
}
if (meta.collection === 'products' && payload.name && !payload.slug) {
payload.slug = generateSlug(payload.name as string)
}
return payload
})
// ===== VALIDATION =====
filter('items.create', async (payload, meta, context) => {
if (meta.collection !== 'orders') return payload
const { database } = context as EventContext & { database: any }
// Check product existence
if (payload.items && Array.isArray(payload.items)) {
for (const item of payload.items) {
const product = await database('products')
.where({ id: item.product_id })
.first()
if (!product) {
throw new Error(`Product ${item.product_id} not found`)
}
if (product.stock < item.quantity) {
throw new Error(`Insufficient stock for "${product.name}"`)
}
}
}
return payload
})
// ===== CACHE INVALIDATION =====
action('items.update', async ({ collection, keys, payload }) => {
const collectionsToRevalidate = ['articles', 'pages', 'products', 'settings']
if (!collectionsToRevalidate.includes(collection)) return
try {
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({ collection, keys }),
})
} catch (err) {
console.error('Failed to revalidate cache:', err)
}
})
// ===== NOTIFICATIONS =====
action('items.create', async ({ collection, key, payload }, context) => {
if (collection !== 'contact_submissions') return
// Notify team in Slack
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📬 New submission from ${payload.name} <${payload.email}>\n${payload.message}`,
}),
})
})
// ===== AUDIT LOG =====
action('items.update', async ({ collection, keys, payload, accountability }) => {
if (!accountability?.user) return
// Log changes to audit_logs collection
const { getSchema } = context as any
const schema = await getSchema()
// ... write to audit_logs
})
// ===== INVENTORY SYNCHRONIZATION =====
action('items.update', async ({ collection, keys, payload }, context) => {
if (collection !== 'orders') return
if (payload.status !== 'paid') return
const { database } = context as EventContext & { database: any }
const order = await database('orders')
.where({ id: keys[0] })
.first()
if (order?.items) {
const items = JSON.parse(order.items)
for (const item of items) {
await database('products')
.where({ id: item.product_id })
.decrement('stock', item.quantity)
}
}
})
// ===== CRON — daily report =====
schedule('0 9 * * 1-5', async () => {
const response = await fetch(`${process.env.API_URL}/custom/reports/sales`)
const stats = await response.json()
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({
text: `📊 Yesterday report: ${stats.count} orders, ${stats.revenue.toLocaleString()} $ revenue`,
}),
})
})
}
function generateSlug(text: string): string {
const translitMap: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'yo',
ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u',
ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
}
return text
.toLowerCase()
.replace(/[а-яё]/g, char => translitMap[char] || char)
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '')
.replace(/-+/g, '-')
.slice(0, 100)
}
Data Access from Hooks
// Via context.database (knex instance)
action('items.create', async (meta, context) => {
const { database, getSchema } = context as any
// Direct SQL via Knex
const related = await database('categories')
.where({ id: meta.payload.category_id })
.first()
// Via ItemsService
const schema = await getSchema()
const { ItemsService } = context as any
const service = new ItemsService('articles', { schema, accountability: meta.accountability })
const items = await service.readByQuery({ filter: { status: { _eq: 'published' } } })
})
Timeline
Development of a set of hooks for business logic (slug, validation, notifications, cache invalidation) — 2–3 days.







