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.