Claude Code for Directus: Open-Source Headless CMS and Data Platform — Claude Skills 360 Blog
Blog / Backend / Claude Code for Directus: Open-Source Headless CMS and Data Platform
Backend

Claude Code for Directus: Open-Source Headless CMS and Data Platform

Published: May 18, 2027
Read time: 7 min read
By: Claude Skills 360

Directus is an open-source headless CMS and data platform — createDirectus(url).with(rest()) creates the SDK client. readItems("articles", { filter: { status: { _eq: "published" } }, sort: ["-date_published"], limit: 10 }) fetches from a collection. readItem("articles", id) gets a single item. createItem("articles", data) inserts; updateItem("articles", id, data) updates. authentication("cookie") handles sessions; staticToken(token) uses API tokens for server-side access. File uploads post to /files endpoint. Relational fields use fields: ["*", "author.*", "tags.*"] for nested expansion. filter: { author: { _eq: "$CURRENT_USER" } } scopes to the authenticated user. WebSocket subscriptions deliver real-time changes. Custom hooks intercept data operations; custom endpoints add REST routes. Directus runs on Docker, Railway, Render, and Koyeb. Claude Code generates Directus TypeScript clients, collection schemas, filter queries, auth flows, and custom extension patterns.

CLAUDE.md for Directus

## Directus Stack
- Version: @directus/sdk >= 17.0
- Init: const directus = createDirectus<Schema>(DIRECTUS_URL).with(rest()).with(staticToken(TOKEN))
- Read: await directus.request(readItems("articles", { filter, sort, fields, limit, offset }))
- Single: await directus.request(readItem("articles", id, { fields: ["*", "author.*"] }))
- Write: await directus.request(createItem("articles", data))
- Update: await directus.request(updateItem("articles", id, patch))
- Auth: createDirectus(URL).with(authentication("cookie")) — then client.login(email, password)
- Types: Schema = { articles: Article[] } — passed to createDirectus<Schema>

TypeScript Client Setup

// lib/cms/directus.ts — typed Directus client
import {
  createDirectus,
  rest,
  staticToken,
  readItems,
  readItem,
  createItem,
  updateItem,
  deleteItem,
  uploadFiles,
  type DirectusClient,
  type RestClient,
} from "@directus/sdk"

// ── Collection types ───────────────────────────────────────────────────────
export type Language = {
  code: string
  name: string
}

export type Author = {
  id: string
  name: string
  bio: string | null
  avatar: string | null
  email: string
}

export type Category = {
  id: number
  name: string
  slug: string
  description: string | null
}

export type Article = {
  id: number
  status: "draft" | "published" | "archived"
  title: string
  slug: string
  content: string
  excerpt: string | null
  date_published: string | null
  author: string | Author  // string (ID) or expanded object
  categories: (number | Category)[]  // IDs or expanded
  cover_image: string | null  // Directus file UUID
  seo_title: string | null
  seo_description: string | null
  reading_time: number | null
}

export type Schema = {
  articles: Article[]
  authors: Author[]
  categories: Category[]
  languages: Language[]
}

// ── Public client (server-side only) ──────────────────────────────────────
export const directus = createDirectus<Schema>(
  process.env.DIRECTUS_URL!,
).with(rest()).with(staticToken(process.env.DIRECTUS_TOKEN!))

// ── Public read-only client (for ISR / RSC) ────────────────────────────────
export const publicDirectus = createDirectus<Schema>(
  process.env.NEXT_PUBLIC_DIRECTUS_URL!,
).with(rest()).with(staticToken(process.env.DIRECTUS_PUBLIC_TOKEN!))

Content Fetching Utilities

// lib/cms/queries.ts — Directus content queries
import { readItems, readItem, aggregate } from "@directus/sdk"
import { directus } from "./directus"
import type { Article, Category, Author } from "./directus"

// Paginated published articles
export async function getArticles(params: {
  page?: number
  limit?: number
  categorySlug?: string
  authorId?: string
}) {
  const { page = 1, limit = 10, categorySlug, authorId } = params

  const filter: Record<string, unknown> = {
    status: { _eq: "published" },
    date_published: { _lte: "$NOW" },
  }

  if (categorySlug) {
    filter["categories"] = {
      categories_id: { slug: { _eq: categorySlug } },
    }
  }

  if (authorId) {
    filter["author"] = { _eq: authorId }
  }

  const articles = await directus.request(
    readItems("articles", {
      filter,
      sort: ["-date_published"],
      limit,
      offset: (page - 1) * limit,
      fields: [
        "id",
        "title",
        "slug",
        "excerpt",
        "date_published",
        "cover_image",
        "reading_time",
        "author.id",
        "author.name",
        "author.avatar",
        "categories.categories_id.id",
        "categories.categories_id.name",
        "categories.categories_id.slug",
      ],
    }),
  )

  return articles as Article[]
}

// Single article by slug
export async function getArticleBySlug(slug: string): Promise<Article | null> {
  const results = await directus.request(
    readItems("articles", {
      filter: {
        slug: { _eq: slug },
        status: { _eq: "published" },
      },
      limit: 1,
      fields: [
        "*",
        "author.*",
        "categories.categories_id.*",
      ],
    }),
  )
  return results[0] as Article ?? null
}

// Total article count for pagination
export async function getArticleCount(categorySlug?: string): Promise<number> {
  const filter: Record<string, unknown> = {
    status: { _eq: "published" },
  }

  if (categorySlug) {
    filter["categories"] = {
      categories_id: { slug: { _eq: categorySlug } },
    }
  }

  const result = await directus.request(
    aggregate("articles", {
      aggregate: { count: "id" },
      query: { filter },
    }),
  )

  return parseInt(String(result[0]?.count?.id ?? 0))
}

// All categories with article counts
export async function getCategories() {
  const categories = await directus.request(
    readItems("categories", {
      sort: ["name"],
      fields: ["id", "name", "slug", "description"],
    }),
  )
  return categories
}

// Get all published slugs for static generation
export async function getAllArticleSlugs(): Promise<string[]> {
  const articles = await directus.request(
    readItems("articles", {
      filter: { status: { _eq: "published" } },
      fields: ["slug"],
      limit: -1,  // No limit
    }),
  )
  return articles.map(a => a.slug as string)
}

File Upload Utility

// lib/cms/uploads.ts — file uploads to Directus Files
import { uploadFiles } from "@directus/sdk"
import { directus } from "./directus"

export type DirectusFile = {
  id: string
  filename_download: string
  type: string
  filesize: number
  width: number | null
  height: number | null
}

// Upload a file to Directus
export async function uploadToDirectus(
  file: File | Blob,
  options: {
    title?: string
    folder?: string  // Folder UUID
    tags?: string[]
  } = {},
): Promise<DirectusFile> {
  const formData = new FormData()
  formData.append("file", file)

  if (options.title) formData.append("title", options.title)
  if (options.folder) formData.append("folder", options.folder)
  if (options.tags?.length) formData.append("tags", JSON.stringify(options.tags))

  const result = await directus.request(uploadFiles(formData))
  return result as DirectusFile
}

// Get Directus asset URL from file ID
export function getAssetUrl(
  fileId: string,
  transforms?: {
    width?: number
    height?: number
    quality?: number
    format?: "webp" | "avif" | "png" | "jpg"
    fit?: "cover" | "contain" | "inside" | "outside"
  },
): string {
  const base = `${process.env.NEXT_PUBLIC_DIRECTUS_URL}/assets/${fileId}`
  if (!transforms) return base

  const params = new URLSearchParams()
  if (transforms.width) params.set("width", String(transforms.width))
  if (transforms.height) params.set("height", String(transforms.height))
  if (transforms.quality) params.set("quality", String(transforms.quality))
  if (transforms.format) params.set("format", transforms.format)
  if (transforms.fit) params.set("fit", transforms.fit)

  return `${base}?${params.toString()}`
}

Next.js Integration

// app/blog/[slug]/page.tsx — Next.js App Router with Directus
import { getArticleBySlug, getAllArticleSlugs } from "@/lib/cms/queries"
import { getAssetUrl } from "@/lib/cms/uploads"
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import type { Author } from "@/lib/cms/directus"

export async function generateStaticParams() {
  const slugs = await getAllArticleSlugs()
  return slugs.map(slug => ({ slug }))
}

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const article = await getArticleBySlug(params.slug)
  if (!article) return {}

  return {
    title: article.seo_title ?? article.title,
    description: article.seo_description ?? article.excerpt ?? undefined,
    openGraph: article.cover_image ? {
      images: [{ url: getAssetUrl(article.cover_image, { width: 1200, height: 630, format: "webp" }) }],
    } : undefined,
  }
}

export default async function ArticlePage({ params }: { params: { slug: string } }) {
  const article = await getArticleBySlug(params.slug)
  if (!article) notFound()

  const author = article.author as Author

  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      {article.cover_image && (
        <img
          src={getAssetUrl(article.cover_image, { width: 1200, height: 600, format: "webp", fit: "cover" })}
          alt={article.title}
          className="w-full aspect-[2/1] object-cover rounded-2xl mb-8"
        />
      )}

      <header className="mb-8">
        <h1 className="text-4xl font-bold tracking-tight mb-4">{article.title}</h1>
        <div className="flex items-center gap-3 text-muted-foreground text-sm">
          {author?.avatar && (
            <img
              src={getAssetUrl(author.avatar, { width: 64, height: 64, format: "webp", fit: "cover" })}
              alt={author.name}
              className="size-8 rounded-full object-cover"
            />
          )}
          <span>{author?.name}</span>
          <span>·</span>
          <time dateTime={article.date_published ?? undefined}>
            {article.date_published
              ? new Date(article.date_published).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
              : "Draft"}
          </time>
          {article.reading_time && (
            <>
              <span>·</span>
              <span>{article.reading_time} min read</span>
            </>
          )}
        </div>
      </header>

      <div
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: article.content }}
      />
    </article>
  )
}

For the Strapi alternative when a more mature, plugin-rich headless CMS with a larger community, REST and GraphQL APIs out of the box, and dedicated cloud hosting is preferred — Strapi has been around longer and has more plugins; Directus is faster to set up for existing databases and has a better data studio UI, see the Strapi guide. For the Payload CMS alternative when a code-first CMS built specifically for TypeScript/Next.js with deeply integrated auth, access control, and blocks is preferred — Payload lives in your repo and generates types from collections while Directus is a separate service, see the Payload CMS guide. The Claude Skills 360 bundle includes Directus skill sets covering SDK queries, file uploads, and Next.js integration. Start with the free tier to try headless CMS generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free