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:

RoleOrdersCustomersProductsReports
AdminFull CRUDFull CRUDFull CRUDFull access
SalesRead, UpdateReadReadOwn only
WarehouseRead, Update statusNo accessRead, Update stockNo 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:

What I Learned

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.

Related Articles