Directus: SQL Database That Actually Becomes a Full API Dashboard
A client had a PostgreSQL database with 30 tables, 5 years of data, and no admin panel. Their ops team was running raw SQL queries through pgAdmin. I connected Directus to the database and in 10 minutes they had: a REST API, a GraphQL API, a visual admin dashboard, and role-based access control. No schema changes, no migrations, no code. Here's how it works and where it breaks.
Connecting to an Existing Database
# Docker setup — connect to existing PostgreSQL
docker run -d \
--name directus \
-p 8055:8055 \
-e KEY=your-secret-key \
-e SECRET=your-admin-secret \
-e DB_CLIENT=pg \
-e DB_HOST=your-postgres-host \
-e DB_PORT=5432 \
-e DB_DATABASE=production_db \
-e DB_USER=readonly_user \
-e DB_PASSWORD=your-password \
directus/directus:latest
# Open http://localhost:8055
# Create admin account on first visit
# Directus scans your schema and generates the admin UI
Directus reads your existing schema. It doesn't modify your tables (unless you create new ones through the UI). Foreign keys become relationships, enums become dropdowns, timestamps become date pickers. It took 10 minutes for our 30-table database.
Auto-Generated REST + GraphQL API
Every table gets full CRUD endpoints automatically:
# REST API — all tables get instant endpoints
GET /items/orders # List orders (with filtering, sorting, pagination)
GET /items/orders/123 # Get single order
POST /items/orders # Create order
PATCH /items/orders/123 # Update order
DELETE /items/orders/123 # Delete order
# Advanced filtering (no custom code needed)
GET "/items/orders?filter[status][_eq]=pending&filter[total][_gt]=100&sort=-created_at&fields=id,customer.name,total"
# Relational data in one query
GET "/items/orders?fields=id,total,customer.name,items.product.name,items.quantity"
# Aggregation
GET "/items/orders?aggregate[sum]=total&groupBy[customer_id]"
# GraphQL — same capabilities
POST /graphql
{
orders(filter: { status: { _eq: "pending" } }, sort: "-created_at") {
id
total
customer { name }
items { product { name } quantity }
}
}
The filtering syntax supports nested relations, aggregation functions, and complex boolean logic. No custom API code needed for 90% of queries.
Problem
A list view of 500 orders, each with 5-10 line items, took 12 seconds to load. Directus was making a separate query for each order's items instead of joining. The N+1 problem in action.
What I Tried
Tried adding database indexes on the foreign key columns. That helped slightly (12s → 8s) but didn't fix the N+1 pattern. Tried limiting the number of relational fields in the API query.
Actual Fix
Directus has a deep parameter for controlling how relational data is loaded. Use it to batch-load relations:
# Use the "deep" parameter to batch-load relations
GET "/items/orders?\
fields=id,total,customer.name&\
deep[items][_sort]=id&\
deep[items][_limit]=10&\
deep[items][fields]=product.name,quantity&\
limit=500"
# Also: add indexes on foreign keys
# (Directus creates these automatically for new relations,
# but your existing database might not have them)
CREATE INDEX idx_items_order_id ON order_items(order_id);
# Result: 500 orders with items loads in 400ms instead of 12s
Building Custom Flows
Directus Flows are visual automation builders. No code needed for common patterns:
// Example: "When an order's status changes to 'shipped',
// send an email and update inventory"
// Flow trigger: event.on_update in "orders" collection
// Condition: {{status}} === "shipped"
// Operation 1: Send email
// {
// to: "{{customer.email}}",
// subject: "Your order {{id}} has shipped!",
// body: "Tracking: {{tracking_number}}"
// }
// Operation 2: Update inventory (custom script)
// Run script operation:
export default async function({ payload }) {
const items = payload.items // Array of { product_id, quantity }
for (const item of items) {
await fetch(`/items/products/${item.product_id}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({
stock: { _decrement: item.quantity }
})
})
}
return { success: true }
}
// Operation 3: Log the shipment
// Insert into audit_log collection
Role-Based Access Control
Directus has granular RBAC built into the admin UI. I set up three roles for the client:
| Role | Orders | Customers | Products | Reports |
|---|---|---|---|---|
| Admin | Full CRUD | Full CRUD | Full CRUD | Full access |
| Sales | Read, Update | Read | Read | Own only |
| Warehouse | Read, Update status | No access | Read, Update stock | No access |
Permissions can be defined down to the field level. The warehouse team can update stock on products but not price. Custom field permissions are configured through the UI, no code needed.
Problem
Created a custom endpoint extension for generating PDF invoices. The endpoint needed to check if the requesting user had permission to view the order, but req.accountability was undefined in the custom route handler.
Actual Fix
Custom endpoints need to opt into authentication by adding the accountability middleware:
// extensions/endpoints/invoice/index.ts
import { defineEndpoint } from "@directus/extensions-sdk"
export default defineEndpoint({
id: "invoice",
handler: (router, { services, database, getSchema, accountability }) => {
const { ItemsService } = services
router.get("/:orderId", async (req, res) => {
// accountability is available — check permissions
const schema = await getSchema()
const orderService = new ItemsService("orders", {
schema,
accountability: req.accountability, // This is the user's auth context
})
const order = await orderService.readOne(req.params.orderId)
if (!order) return res.status(404).json({ error: "Not found" })
// Generate PDF...
const pdf = await generateInvoicePDF(order)
res.setHeader("Content-Type", "application/pdf")
res.send(pdf)
})
},
})
Extending with Custom Endpoints
When the auto-generated API isn't enough, you write extensions. Directus supports several extension types:
- Endpoints: Custom API routes
- Hooks: Before/after database operations
- Interfaces: Custom field types in the admin UI
- Modules: Custom pages in the admin sidebar
- Panels: Custom dashboard widgets
What I Learned
- Directus is magic for existing databases. Connect it to any PostgreSQL/MySQL/SQLite database and get instant API + admin. Zero code.
- Watch out for N+1 on relational queries. Use the
deepparameter and add indexes on foreign keys. - Flows handle 80% of automation needs. Before writing a custom hook, check if a Flow can do it.
- RBAC is powerful but plan roles early. Retroactively adding field-level permissions to a large team is tedious.
- Keep your database schema clean. Directus reflects your schema. Messy schema = messy admin UI.
Wrapping Up
Directus is the fastest way to put an admin panel on top of an existing SQL database. The auto-generated API handles most queries, the visual flows handle most automation, and the RBAC system handles most access control needs. For anything custom, the extension system is well-documented and type-safe. If you have a database that needs an admin panel and API, Directus should be your first stop.