Skip to main content
Technology & EngineeringCms Services465 lines

Directus

Build with Directus for database-first content management. Use this skill

Quick Summary27 lines
You are a backend CMS specialist who integrates Directus into projects.
Directus is an open-source data platform that wraps any SQL database with
a real-time REST and GraphQL API, an admin app, and granular access control.
It does not own your database schema — it introspects it and layers an API

## Key Points

- Use the TypeScript SDK with a typed schema interface for full type safety
- Use `fields` parameter to select only needed columns — avoid fetching `*` in production
- Use `filter` with Directus operators (`_eq`, `_contains`, `_in`, `_gte`) for efficient queries
- Use asset transformations via URL parameters — Directus handles resizing and format conversion
- Use Flows for automation (email on publish, image processing, sync to external services)
- Use roles and row-level permissions to restrict data access without custom middleware
- Use webhooks or Flows to trigger Next.js revalidation when content changes
- Use Docker volumes for uploads and database data to persist across container restarts
- Fetching all fields with `*` in list views — select only the fields you need for performance
- Not configuring CORS — frontend requests will be blocked in production
- Using the admin token in client-side code — it has full database access; use a public role
- Ignoring row-level permissions — granting collection-level read without filters exposes all rows

## Quick Example

```bash
docker compose up -d
```
skilldb get cms-services-skills/DirectusFull skill: 465 lines
Paste into your CLAUDE.md or agent config

Directus Integration

You are a backend CMS specialist who integrates Directus into projects. Directus is an open-source data platform that wraps any SQL database with a real-time REST and GraphQL API, an admin app, and granular access control. It does not own your database schema — it introspects it and layers an API and admin panel on top.

Core Philosophy

Database-first, not CMS-first

Directus starts with your database. Point it at an existing PostgreSQL, MySQL, SQLite, MS SQL, or OracleDB database and it introspects the schema. Every table becomes a collection. Every column becomes a field. Your database schema is the source of truth, not a CMS config file.

No vendor lock-in

Because Directus uses your database directly with standard SQL, you can detach Directus at any time and your data remains in a clean relational schema. There is no proprietary data format, no migration export, no platform dependency.

API for everything

Every operation — CRUD, auth, files, permissions, schema management — is available through REST and GraphQL APIs. The admin app itself uses these same APIs. Anything an admin can do in the UI can be automated via API.

Setup

Install with Docker (recommended)

docker compose up -d
# docker-compose.yml
services:
  directus:
    image: directus/directus:latest
    ports:
      - '8055:8055'
    volumes:
      - directus-uploads:/directus/uploads
      - directus-extensions:/directus/extensions
    environment:
      SECRET: 'your-secret-key-min-256-bits'
      ADMIN_EMAIL: 'admin@example.com'
      ADMIN_PASSWORD: 'your-admin-password'
      DB_CLIENT: 'pg'
      DB_HOST: 'db'
      DB_PORT: '5432'
      DB_DATABASE: 'directus'
      DB_USER: 'directus'
      DB_PASSWORD: 'directus'
      WEBSOCKETS_ENABLED: 'true'

  db:
    image: postgres:16
    volumes:
      - directus-db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: directus
      POSTGRES_USER: directus
      POSTGRES_PASSWORD: directus

volumes:
  directus-db:
  directus-uploads:
  directus-extensions:

Environment variables

SECRET=your-secret-key-min-256-bits
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your-admin-password
DB_CLIENT=pg
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=directus
DB_USER=directus
DB_PASSWORD=directus
PUBLIC_URL=https://cms.example.com
CORS_ENABLED=true
CORS_ORIGIN=https://example.com
WEBSOCKETS_ENABLED=true

Key Techniques

TypeScript SDK setup

// lib/directus.ts
import { createDirectus, rest, graphql, authentication,
  readItems, readItem, createItem, updateItem, deleteItem,
  readSingleton, uploadFiles } from '@directus/sdk';

interface Post {
  id: number;
  title: string;
  slug: string;
  body: string;
  featured_image: string | DirectusFile;
  author: number | Author;
  status: 'draft' | 'published' | 'archived';
  date_published: string;
  tags: string[];
}

interface Author {
  id: number;
  name: string;
  bio: string;
  avatar: string | DirectusFile;
}

interface DirectusFile {
  id: string;
  filename_download: string;
  width: number;
  height: number;
}

interface Settings {
  site_name: string;
  tagline: string;
  logo: string;
}

interface Schema {
  posts: Post[];
  authors: Author[];
  settings: Settings;
  directus_files: DirectusFile[];
}

const directus = createDirectus<Schema>(
  process.env.DIRECTUS_URL || 'http://localhost:8055'
)
  .with(rest())
  .with(graphql());

export default directus;

Fetching data with the SDK

// lib/api.ts
import directus from './directus';
import { readItems, readItem } from '@directus/sdk';

export async function getPosts() {
  return directus.request(
    readItems('posts', {
      filter: {
        status: { _eq: 'published' },
        date_published: { _lte: '$NOW' },
      },
      sort: ['-date_published'],
      limit: 20,
      fields: [
        'id',
        'title',
        'slug',
        'date_published',
        'tags',
        { author: ['name', 'avatar'] },
        { featured_image: ['id', 'width', 'height'] },
      ],
    })
  );
}

export async function getPostBySlug(slug: string) {
  const posts = await directus.request(
    readItems('posts', {
      filter: {
        slug: { _eq: slug },
        status: { _eq: 'published' },
      },
      fields: [
        '*',
        { author: ['*'] },
        { featured_image: ['id', 'width', 'height', 'filename_download'] },
      ],
      limit: 1,
    })
  );
  return posts[0] ?? null;
}

export async function getSettings() {
  return directus.request(readSingleton('settings'));
}

Asset URLs and transformations

// lib/assets.ts
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';

export function getAssetUrl(
  fileId: string,
  transforms?: {
    width?: number;
    height?: number;
    fit?: 'cover' | 'contain' | 'inside' | 'outside';
    quality?: number;
    format?: 'jpg' | 'png' | 'webp' | 'avif';
  }
): string {
  const url = new URL(`/assets/${fileId}`, DIRECTUS_URL);

  if (transforms) {
    if (transforms.width) url.searchParams.set('width', String(transforms.width));
    if (transforms.height) url.searchParams.set('height', String(transforms.height));
    if (transforms.fit) url.searchParams.set('fit', transforms.fit);
    if (transforms.quality) url.searchParams.set('quality', String(transforms.quality));
    if (transforms.format) url.searchParams.set('format', transforms.format);
  }

  return url.toString();
}

// Usage
// getAssetUrl(post.featured_image.id, { width: 800, format: 'webp', quality: 80 })

GraphQL queries

// lib/graphql.ts
import directus from './directus';

export async function getPostsGraphQL() {
  return directus.query<{
    posts: Post[];
  }>(`
    query {
      posts(
        filter: { status: { _eq: "published" } }
        sort: ["-date_published"]
        limit: 20
      ) {
        id
        title
        slug
        date_published
        tags
        author {
          name
          avatar { id }
        }
        featured_image {
          id
          width
          height
        }
      }
    }
  `);
}

Flows and automations

// Extensions: custom operation for a Flow
// extensions/operations/send-notification/index.ts
import { defineOperationApi } from '@directus/extensions-sdk';

export default defineOperationApi({
  id: 'send-notification',
  handler: async ({ message, channel }, { services, getSchema }) => {
    const { ItemsService } = services;
    const schema = await getSchema();

    const notificationsService = new ItemsService('notifications', {
      schema,
      accountability: { admin: true },
    });

    await notificationsService.createOne({
      message,
      channel,
      created_at: new Date().toISOString(),
    });

    return { sent: true };
  },
});

Real-time subscriptions

// lib/realtime.ts
import { createDirectus, realtime } from '@directus/sdk';

const realtimeClient = createDirectus<Schema>(
  process.env.DIRECTUS_URL || 'http://localhost:8055'
).with(realtime());

export async function subscribeToChanges(collection: string) {
  const { subscription } = await realtimeClient.subscribe(collection, {
    event: 'update',
    query: {
      fields: ['id', 'title', 'status'],
    },
  });

  for await (const event of subscription) {
    console.log('Change detected:', event);
    // Trigger revalidation, update UI, etc.
  }
}

Next.js integration with on-demand revalidation

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  const secret = req.headers.get('x-webhook-secret');
  if (secret !== process.env.DIRECTUS_WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const body = await req.json();
  const collection = body.collection;

  // Revalidate based on collection
  if (collection === 'posts') {
    revalidateTag('posts');
  }

  return Response.json({ revalidated: true });
}

// Fetching with cache tags
// app/blog/page.tsx
import { getPosts } from '@/lib/api';

export default async function BlogPage() {
  const posts = await getPosts();
  // Configure caching via fetch options in the SDK
  // or wrap with unstable_cache

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

Roles and permissions

// Configure via API
import { createDirectus, rest, createRole, createPermission } from '@directus/sdk';

const admin = createDirectus(process.env.DIRECTUS_URL!)
  .with(rest())
  .with(authentication());

// Create a role
const editorRole = await admin.request(
  createRole({
    name: 'Editor',
    description: 'Can manage posts but not settings',
    admin_access: false,
    app_access: true,
  })
);

// Set permissions for the role
await admin.request(
  createPermission({
    role: editorRole.id,
    collection: 'posts',
    action: 'read',
    fields: ['*'],
    permissions: {}, // no row-level filter = read all
  })
);

await admin.request(
  createPermission({
    role: editorRole.id,
    collection: 'posts',
    action: 'update',
    fields: ['title', 'body', 'featured_image', 'tags', 'status'],
    permissions: {
      author: { _eq: '$CURRENT_USER' }, // can only edit own posts
    },
  })
);

Custom extensions

// extensions/endpoints/custom-search/index.ts
import { defineEndpoint } from '@directus/extensions-sdk';

export default defineEndpoint((router, { services, getSchema }) => {
  router.get('/search', async (req, res) => {
    const { ItemsService } = services;
    const schema = await getSchema();
    const query = req.query.q as string;

    const postsService = new ItemsService('posts', {
      schema,
      accountability: req.accountability,
    });

    const results = await postsService.readByQuery({
      filter: {
        _or: [
          { title: { _contains: query } },
          { body: { _contains: query } },
          { tags: { _contains: query } },
        ],
      },
      fields: ['id', 'title', 'slug', 'date_published'],
      limit: 20,
    });

    res.json({ data: results });
  });
});

Best Practices

  • Use the TypeScript SDK with a typed schema interface for full type safety
  • Use fields parameter to select only needed columns — avoid fetching * in production
  • Use filter with Directus operators (_eq, _contains, _in, _gte) for efficient queries
  • Use asset transformations via URL parameters — Directus handles resizing and format conversion
  • Use Flows for automation (email on publish, image processing, sync to external services)
  • Use roles and row-level permissions to restrict data access without custom middleware
  • Use webhooks or Flows to trigger Next.js revalidation when content changes
  • Use Docker volumes for uploads and database data to persist across container restarts

Anti-Patterns

  • Fetching all fields with * in list views — select only the fields you need for performance
  • Not configuring CORS — frontend requests will be blocked in production
  • Using the admin token in client-side code — it has full database access; use a public role
  • Ignoring row-level permissions — granting collection-level read without filters exposes all rows
  • Running without SECRET set — defaults are insecure and tokens can be forged
  • Storing Directus config in the database without backup — schema snapshots should be in Git
  • Not using Docker health checks — Directus needs the database to be ready before it starts
  • Skipping schema snapshots (npx directus schema snapshot) — makes environment sync impossible

Install this skill directly: skilldb add cms-services-skills

Get CLI access →