Skip to content
Sign inJoin waitlist

Connect Directus to an Astro template

The Astro edition is a static site: every page is plain HTML, built ahead of time. Directus fits it as a separate app — an admin and an API on top of a SQL database you own — that the site reads while it builds. You host it yourself; this guide runs it in Docker and sets up both for the Nitip Astro edition: at the end, editors manage services, projects, posts, jobs, team and testimonials in Directus, and the site rebuilds from it when they save.

We followed every step below on a fresh copy of Nitip 1.0.0 with Directus 12.4 and Astro 7.3, on a local machine — the setup, the import, the static build, and editing, drafts and reordering in Directus with the site picking them up. Every other Framebrick Astro template works the same way: its collections come from the same kind of loaders in src/content.config.ts, so only the collections and fields differ — its Using a headless CMS page lists them.

my-site/
├── nitip-astro/ the website: this template
└── nitip-directus/ Directus in Docker: the admin at localhost:8055/admin and the API
  • The site’s collections load from Directus’ API instead of the JSON files, as a Site user with a read-only key that asks for published items only. The schemas stay, so every entry is checked before a page uses it, and the pages and components don’t change.
  • When an editor saves, a Directus Flow calls one URL. While you work, that’s the Astro dev server, which reloads the content. On a host, it’s the deploy hook that rebuilds the site.
  • Images are downloaded from Directus during the build and optimized like the template’s own, so visitors never load anything from Directus.
  • The Nitip Astro edition, unzipped into a folder of its own (my-site/nitip-astro), with npm install done and npm run dev working (Install and run).
  • Docker — Directus’ recommended way to run it.
  • Node.js 22.12 or newer, and about an hour.

In my-site, create the folder nitip-directus with two files. nitip-directus/docker-compose.yml:

nitip-directus/docker-compose.yml
# Directus for the site: the admin at http://localhost:8055/admin and the API. Settings and secrets are in .env.
services:
directus:
image: directus/directus:12.4.1
ports:
- 8055:8055
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
env_file: .env
# Lets Directus call the site on this machine (Flows), on Linux too.
extra_hosts:
- host.docker.internal:host-gateway
environment:
DB_CLIENT: sqlite3
DB_FILENAME: /directus/database/data.db
WEBSOCKETS_ENABLED: "false"

nitip-directus/.env — the secrets, the first admin and the site’s key:

nitip-directus/.env
# Directus signs logins with SECRET; ADMIN_* create the first admin; ADMIN_TOKEN lets the scripts in scripts/ work as that admin.
SECRET=replace-with-a-long-random-string
ADMIN_EMAIL=you@yourcompany.com
ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_TOKEN=replace-with-another-long-random-string
# Where Directus runs. On a server: its public address.
PUBLIC_URL=http://localhost:8055
# The site's read-only key: step 2 gives it to a "Site" user, and nitip-astro/.env repeats it as DIRECTUS_TOKEN.
SITE_TOKEN=replace-with-a-third-long-random-string

Use an email address with a real domain — Directus refuses addresses like admin@site.test — and leave $ out of the values: Docker Compose reads it as the start of a variable. To make each random string:

Terminal window
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"

Then create the folders for the database and the uploads, and start Directus:

Terminal window
cd nitip-directus
mkdir database
mkdir uploads
docker compose up -d

Open localhost:8055/admin and sign in with ADMIN_EMAIL and ADMIN_PASSWORD. The first time, Directus asks a few setup questions — the owner’s email, what you use it for, and whether you have a license key: I’m using Core plan is free and needs none. The database and the uploads live in database/ and uploads/, so they survive restarts. Keep .env, database/ and uploads/ out of Git.

This script creates the site’s collections through Directus’ API — one per collection, mirroring nitip-astro/src/content/schemas.ts — and the Site user the site reads them with. Create nitip-directus/scripts/define-content.mjs:

nitip-directus/scripts/define-content.mjs
/*
* Creates the site's collections in Directus and the "Site" user the site reads them with — run once, with Directus up:
* node --env-file=.env scripts/define-content.mjs
* Each collection mirrors a schema in the Astro project's src/content/schemas.ts. Afterwards change them in the admin's
* Settings → Data Model.
*/
const url = (process.env.PUBLIC_URL ?? "http://localhost:8055").replace(/\/+$/, "");
if (!process.env.SITE_TOKEN) throw new Error("Set SITE_TOKEN in .env first (step 1).");
async function api(method, path, body) {
const response = await fetch(`${url}${path}`, {
method,
headers: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}`, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`);
return response.status === 204 ? null : (await response.json()).data;
}
/* Fields */
const id = { field: "id", type: "integer", meta: { hidden: true }, schema: { is_primary_key: true, has_auto_increment: true } };
/* Drag-and-drop order in the admin's lists; the site shows entries in this order. */
const sort = { field: "sort", type: "integer", meta: { hidden: true } };
const status = {
field: "status",
type: "string",
meta: {
width: "half",
interface: "select-dropdown",
display: "labels",
options: { choices: [{ text: "Published", value: "published" }, { text: "Draft", value: "draft" }] },
},
schema: { default_value: "draft", is_nullable: false },
};
const string = (field, extra = {}) => ({ field, type: "string", meta: { width: "half", interface: "input", ...extra } });
const text = (field, extra = {}) => ({ field, type: "text", meta: { width: "full", interface: "input-multiline", ...extra } });
const required = (f) => ({ ...f, meta: { ...f.meta, required: true }, schema: { ...f.schema, is_nullable: false } });
const slug = { ...required(string("slug", { note: "The page address: lowercase words joined by hyphens.", options: { slug: true } })), schema: { is_unique: true, is_nullable: false } };
/* Every picture in the design is required: a missing one would break its page. */
const file = (field, note) => ({ field, type: "uuid", meta: { width: "half", interface: "file-image", special: ["file"], required: true, note }, schema: {} });
/* A repeater: a list of small forms, stored as JSON. */
const list = (field, fields, template, note) => ({ field, type: "json", meta: { width: "full", interface: "list", special: ["cast-json"], note, options: { fields, template } } });
const sub = (field, type, iface, width = "full") => ({ field, name: field, type, meta: { field, type, interface: iface, width } });
const highlighted = (field, note) => list(field, [sub("highlight", "string", "input", "half"), sub("text", "text", "input-multiline")], "{{highlight}}{{text}}", note);
const lines = (field) => list(field, [sub("text", "text", "input-multiline")], "{{text}}");
const group = (field, name) => ({ field, type: "alias", meta: { interface: "group-detail", special: ["alias", "no-data", "group"], options: { start: "open" }, note: name } });
const inGroup = (f, groupName) => ({ ...f, meta: { ...f.meta, group: groupName } });
const collection = (name, icon, template, fields) =>
api("POST", "/collections", {
collection: name,
meta: { icon, display_template: template, sort_field: "sort", archive_field: null },
schema: {},
fields: [id, status, sort, ...fields],
});
await collection("services", "work", "{{title}}", [
required(string("title")),
slug,
string("number", { note: "Shown before the title, e.g. 01." }),
file("image_1", "First of the two pictures."),
file("image_2", "Second picture."),
text("description"),
list("tools", [sub("label", "string", "input", "half"), sub("href", "string", "input", "half")], "{{label}}"),
highlighted("offer", "What you offer; each can start with a bold phrase."),
highlighted("benefits"),
list(
"plans",
[{ ...sub("key", "string", "input", "half"), meta: { field: "key", type: "string", interface: "input", width: "half", note: "Unique within the service, e.g. weekly." } }, sub("label", "string", "input", "half"), sub("price", "string", "input", "half"), sub("note", "string", "input", "half"), { ...sub("included", "text", "input-multiline"), meta: { field: "included", type: "text", interface: "input-multiline", width: "full", note: "One item per line." } }],
"{{label}} — {{price}}",
),
]);
await collection("projects", "folder_special", "{{title}}", [
required(string("title")),
slug,
string("year"),
file("image", "Card image, also the main image on the project page."),
{ field: "services", type: "alias", meta: { interface: "list-m2m", special: ["m2m"], options: { template: "{{services_id.title}}" } } },
text("summary"),
string("industry"),
group("overview", "Overview"),
inGroup(text("overview_text"), "overview"),
inGroup(file("overview_image"), "overview"),
group("process", "Process"),
inGroup(text("process_text"), "process"),
inGroup(file("process_image_1"), "process"),
inGroup(file("process_image_2"), "process"),
group("result", "Result"),
inGroup(highlighted("result_items"), "result"),
inGroup(file("result_image"), "result"),
]);
/* Post body blocks (src/content/schemas.ts → PostBlock). A list item is one or more paragraphs, each optionally followed
by a bulleted sub-list. */
const block = (name, icon, template, fields) =>
api("POST", "/collections", { collection: name, meta: { icon, hidden: true, display_template: template }, schema: {}, fields: [id, ...fields] });
await block("block_heading", "title", "{{bold}}{{text}}", [string("bold"), string("text")]);
await block("block_paragraph", "notes", "{{highlight}}{{text}}", [string("highlight"), text("text")]);
await block("block_list", "format_list_bulleted", "List", [
{ field: "ordered", type: "boolean", meta: { width: "half", interface: "boolean", special: ["cast-boolean"], options: { label: "Numbered list" } }, schema: { default_value: false } },
list(
"items",
[
{
field: "parts",
name: "parts",
type: "json",
meta: {
field: "parts",
type: "json",
interface: "list",
width: "full",
options: {
template: "{{highlight}}{{text}}",
fields: [
sub("highlight", "string", "input", "half"),
sub("text", "text", "input-multiline"),
{ field: "sub_items", name: "sub_items", type: "json", meta: { field: "sub_items", type: "json", interface: "list", width: "full", options: { template: "{{highlight}}{{text}}", fields: [sub("highlight", "string", "input", "half"), sub("text", "text", "input-multiline")] } } },
],
},
},
},
],
"{{parts}}",
"Each item holds one or more paragraphs; a paragraph can have a bulleted sub-list.",
),
]);
await collection("posts", "article", "{{title}}", [
required(string("title")),
slug,
text("excerpt"),
file("image"),
string("category"),
{ field: "date", type: "date", meta: { width: "half", interface: "datetime", required: true }, schema: { is_nullable: false } },
{ field: "body", type: "alias", meta: { interface: "list-m2a", special: ["m2a"] } },
]);
await collection("careers", "badge", "{{title}}", [required(string("title")), slug, text("summary"), lines("responsibilities"), lines("requirements"), lines("benefits")]);
await collection("team", "group", "{{name}}", [required(string("name")), string("role"), string("email"), file("photo")]);
await collection("testimonials", "format_quote", "{{name}}", [
required(text("quote")),
required(string("name")),
string("company"),
string("service", { note: "The service the client used, shown under the name." }),
file("photo"),
file("logo"),
]);
/* Files point at Directus' own files collection. */
const fileFields = {
services: ["image_1", "image_2"],
projects: ["image", "overview_image", "process_image_1", "process_image_2", "result_image"],
posts: ["image"],
team: ["photo"],
testimonials: ["photo", "logo"],
};
for (const [name, fields] of Object.entries(fileFields))
for (const field of fields) await api("POST", "/relations", { collection: name, field, related_collection: "directus_files", schema: { on_delete: "SET NULL" } });
/* Projects ↔ services, in the order they're listed. */
await api("POST", "/collections", {
collection: "projects_services",
meta: { hidden: true, icon: "import_export" },
schema: {},
fields: [id, { field: "projects_id", type: "integer", meta: { hidden: true } }, { field: "services_id", type: "integer", meta: { hidden: true } }, sort],
});
await api("POST", "/relations", { collection: "projects_services", field: "projects_id", related_collection: "projects", meta: { one_field: "services", junction_field: "services_id", sort_field: "sort" }, schema: { on_delete: "CASCADE" } });
await api("POST", "/relations", { collection: "projects_services", field: "services_id", related_collection: "services", meta: { junction_field: "projects_id" }, schema: { on_delete: "CASCADE" } });
/* Post body: a builder of the three blocks. */
await api("POST", "/collections", {
collection: "posts_body",
meta: { hidden: true, icon: "import_export" },
schema: {},
fields: [id, { field: "posts_id", type: "integer", meta: { hidden: true } }, { field: "item", type: "string", meta: { hidden: true } }, { field: "collection", type: "string", meta: { hidden: true } }, sort],
});
await api("POST", "/relations", {
collection: "posts_body",
field: "item",
related_collection: null,
meta: { one_allowed_collections: ["block_heading", "block_paragraph", "block_list"], one_collection_field: "collection", junction_field: "posts_id" },
});
await api("POST", "/relations", { collection: "posts_body", field: "posts_id", related_collection: "posts", meta: { one_field: "body", junction_field: "item", sort_field: "sort" }, schema: { on_delete: "CASCADE" } });
/* Access. The site reads with its own key: an API-only "Site" user (no Studio access, so it takes no seat) that may read
these collections, drafts included — the site asks for published items itself. Directus' free Core tier has no
per-item rules, so that filter can't live here. The public may read files only, so images load anywhere. */
const site = await api("POST", "/policies", { name: "Site", icon: "language", app_access: false, admin_access: false });
const collections = ["services", "projects", "posts", "careers", "team", "testimonials", "block_heading", "block_paragraph", "block_list", "projects_services", "posts_body", "directus_files"];
for (const name of collections) await api("POST", "/permissions", { policy: site.id, collection: name, action: "read", fields: ["*"], permissions: {} });
await api("POST", "/users", { first_name: "Site", token: process.env.SITE_TOKEN, policies: [{ policy: site.id }] });
const [publicPolicy] = await api("GET", "/policies?filter[name][_eq]=$t:public_label&fields=id");
await api("POST", "/permissions", { policy: publicPolicy.id, collection: "directus_files", action: "read", fields: ["*"], permissions: {} });
console.log("Created 6 collections, 3 post blocks, the Site user and public access to files.");

Run it in nitip-directus, with Directus up:

Terminal window
node --env-file=.env scripts/define-content.mjs

A few choices worth knowing:

  • Order. Each collection has a hidden sort field, so its list in the admin can be reordered by drag and drop; the site shows items in that order.
  • Publishing. Each item has a Status: Published items reach the site, Draft ones don’t. New items start as drafts.
  • Images are file fields, all required, as every picture in the design is; a service’s two pictures are Image 1 and Image 2. To change a picture, choose or upload another file in the item — replacing a file in the File Library doesn’t trigger a rebuild. Don’t delete a file there while an item still uses it: the next build stops on that item.
  • Post bodies are a Builder of Heading, Paragraph and List blocks — the blocks the template renders. A list item is one or more paragraphs, each optionally followed by a bulleted sub-list, which covers every post in the demo.
  • Pricing plans list what’s included one item per line; give each plan of a service its own Key (weekly, monthly…).
  • Access. The Site user may read these collections, drafts included, and can’t sign in to the admin, so it takes none of Core’s three seats. The public may read files only — so keep private documents out of Directus’ file library.

The import script reads the demo content straight from nitip-astro. Create nitip-directus/scripts/seed.mts:

nitip-directus/scripts/seed.mts
/*
* Copies the demo content of the Astro site into Directus, published, once — with Directus up:
* npx tsx --env-file=.env scripts/seed.mts
* Reads the JSON in <site>/src/content/collections and uploads the images from <site>/src/assets/images. It stops if
* services already exist.
*/
import fs from "node:fs";
import path from "node:path";
import type { Career, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "../../nitip-astro/src/content/schemas";
/* The Astro project, in the folder next to this one. */
const site = path.resolve("../nitip-astro");
const url = (process.env.PUBLIC_URL ?? "http://localhost:8055").replace(/\/+$/, "");
const headers = { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const TYPES: Record<string, string> = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".svg": "image/svg+xml" };
const read = <T,>(name: string): T[] => JSON.parse(fs.readFileSync(path.join(site, "src/content/collections", `${name}.json`), "utf8"));
async function api(method: string, route: string, body?: unknown) {
const response = await fetch(`${url}${route}`, {
method,
headers: { ...headers, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) throw new Error(`${method} ${route}: ${response.status} ${await response.text()}`);
return (await response.json()).data;
}
/* "/images/blog-1.jpg" (src/assets/images/blog-1.jpg) → the id of its uploaded file, uploading each file once. */
const uploaded = new Map<string, string>();
async function media(src: string) {
if (!uploaded.has(src)) {
const form = new FormData();
form.append("file", new Blob([fs.readFileSync(path.join(site, "src/assets", src))], { type: TYPES[path.extname(src)] }), path.basename(src));
const response = await fetch(`${url}/files`, { method: "POST", headers, body: form });
if (!response.ok) throw new Error(`Upload ${src}: ${response.status} ${await response.text()}`);
uploaded.set(src, (await response.json()).data.id);
}
return uploaded.get(src)!;
}
const lines = (items: string[]) => items.map((text) => ({ text }));
const create = (collection: string, item: Record<string, unknown>) => api("POST", `/items/${collection}`, { status: "published", ...item });
/* A list item's blocks → its parts: each paragraph with the bulleted sub-list that follows it. */
function parts(item: PostBlock[]) {
const result: { highlight?: string; text: string; sub_items: { highlight?: string; text: string }[] }[] = [];
for (const block of item) {
if (block.type === "paragraph") result.push({ highlight: block.highlight, text: block.text, sub_items: [] });
else if (block.type === "list" && result.length)
result[result.length - 1].sub_items = block.items.map(([first]) => (first.type === "paragraph" ? { highlight: first.highlight, text: first.text } : { text: "" }));
else throw new Error(`A list item holds a ${block.type} the Directus model doesn't cover.`);
}
return result;
}
const body = (blocks: PostBlock[]) =>
blocks.map((block, i) => {
const sort = i + 1;
if (block.type === "heading") return { collection: "block_heading", item: { bold: block.bold, text: block.text }, sort };
if (block.type === "paragraph") return { collection: "block_paragraph", item: { highlight: block.highlight, text: block.text }, sort };
return { collection: "block_list", item: { ordered: block.ordered ?? false, items: block.items.map((item) => ({ parts: parts(item) })) }, sort };
});
if ((await api("GET", "/items/services?aggregate[count]=*"))[0].count > 0) {
console.log("Services already exist — the database is seeded. Stop Directus and empty database/ and uploads/ to start over.");
process.exit(0);
}
const services = read<Service>("services");
const projects = read<Project>("projects");
const posts = read<Post>("posts");
const careers = read<Career>("careers");
const team = read<TeamMember>("team");
const testimonials = read<Testimonial>("testimonials");
const serviceIds = new Map<string, number>();
for (const [i, { images, plans, ...service }] of services.entries()) {
const item = await create("services", {
...service,
sort: i + 1,
image_1: await media(images[0]),
image_2: await media(images[1]),
plans: plans.map(({ id, included, ...plan }) => ({ key: id, ...plan, included: included.join("\n") })),
});
serviceIds.set(service.slug, item.id);
}
for (const [i, project] of projects.entries())
await create("projects", {
title: project.title,
slug: project.slug,
year: project.year,
summary: project.summary,
industry: project.industry,
sort: i + 1,
image: await media(project.image),
services: project.services.map((ref, j) => ({ services_id: serviceIds.get(ref.slug), sort: j + 1 })),
overview_text: project.overview.text,
overview_image: await media(project.overview.image),
process_text: project.process.text,
process_image_1: await media(project.process.images[0]),
process_image_2: await media(project.process.images[1]),
result_items: project.result.items,
result_image: await media(project.result.image),
});
for (const [i, post] of posts.entries()) await create("posts", { ...post, sort: i + 1, image: await media(post.image), body: body(post.body) });
for (const [i, career] of careers.entries())
await create("careers", {
...career,
sort: i + 1,
responsibilities: lines(career.responsibilities),
requirements: lines(career.requirements),
benefits: lines(career.benefits),
});
for (const [i, member] of team.entries()) await create("team", { ...member, sort: i + 1, photo: await media(member.photo) });
for (const [i, testimonial] of testimonials.entries())
await create("testimonials", { ...testimonial, sort: i + 1, photo: await media(testimonial.photo), logo: await media(testimonial.logo) });
console.log(
`Seeded ${services.length} services, ${projects.length} projects, ${posts.length} posts, ${careers.length} careers, ${team.length} team members, ${testimonials.length} testimonials and ${uploaded.size} images.`,
);

Run it in nitip-directus, with Directus up (npx fetches tsx the first time):

Terminal window
npx tsx --env-file=.env scripts/seed.mts

It uploads the images, creates every item as published and ends with:

Seeded 8 services, 8 projects, 12 posts, 12 careers, 6 team members, 3 testimonials and 80 images.

4. Load the site’s collections from Directus

Section titled “4. Load the site’s collections from Directus”

Everything in this step happens in nitip-astro.

.env — where Directus runs, and the Site user’s key (create the file if you haven’t for the forms):

nitip-astro/.env
# Where Directus runs. On a host: Directus' public address.
DIRECTUS_URL=http://localhost:8055
# The Site user's key: SITE_TOKEN from nitip-directus/.env.
DIRECTUS_TOKEN=replace-with-the-site-token

The loaders. Create nitip-astro/src/lib/directus.ts. Each loader reads one collection with the fields it needs and maps its items to the template’s schema:

nitip-astro/src/lib/directus.ts
/*
* Collections from Directus, next to this project. Each loader fetches one collection through Directus' REST API when
* the site builds (and when the dev server refreshes, see src/integrations/cms-refresh.ts) and maps the items to the
* schemas in src/content/schemas.ts, which check every entry before a page uses it.
*
* It reads as the "Site" user (DIRECTUS_TOKEN), which can see drafts too, so every request asks for published items.
*/
import type { PostBlock } from "../content/schemas";
/** Where Directus runs: DIRECTUS_URL in .env. */
export const directusUrl = (import.meta.env.DIRECTUS_URL ?? "http://localhost:8055").replace(/\/+$/, "");
type Item = Record<string, any>;
type Query = { fields: string[]; deep?: Record<string, string>; key?: string; map: (item: Item) => Item };
/** A loader for one collection: its published items, in the admin's drag-and-drop order (`position`). */
function directus(collection: string, { fields, deep = {}, key = "slug", map }: Query) {
return async () => {
const search = new URLSearchParams({ fields: fields.join(","), sort: "sort", limit: "-1", "filter[status][_eq]": "published", ...deep });
const response = await fetch(`${directusUrl}/items/${collection}?${search}`, {
headers: { Authorization: `Bearer ${import.meta.env.DIRECTUS_TOKEN}` },
}).catch(() => undefined);
if (!response?.ok)
throw new Error(`Couldn't load ${collection} from Directus at ${directusUrl} (${response?.status ?? "no answer"}). Is it running, and is DIRECTUS_TOKEN set?`);
const { data } = (await response.json()) as { data: Item[] };
return data.map((item, position) => {
const entry = map(item);
return { id: String(entry[key]), position, ...entry };
});
};
}
/* A file field → the public address Directus serves it at, ending with its file name (an SVG stays an SVG). A missing
file (deleted from the File Library) stays undefined, so the schema check names the entry. */
const files = (...names: string[]) => names.flatMap((name) => [`${name}.id`, `${name}.filename_download`]);
const src = (file: Item | null | undefined) => (file ? `${directusUrl}/assets/${file.id}/${encodeURIComponent(file.filename_download)}` : undefined);
const text = (value: string | null | undefined) => value ?? "";
const optional = (value: string | null | undefined) => value || undefined;
const highlighted = (items: Item[] | null | undefined) =>
(items ?? []).map((item) => ({ highlight: optional(item.highlight), text: text(item.text) }));
const lines = (items: Item[] | null | undefined) => (items ?? []).map((item) => text(item.text));
/* A builder block → the blocks the template renders. A list item's parts are paragraphs, each with the bulleted
sub-list that follows it. */
const paragraph = (item: Item): PostBlock => ({ type: "paragraph", highlight: optional(item.highlight), text: text(item.text) });
function block({ collection, item }: Item): PostBlock {
if (collection === "block_heading") return { type: "heading", text: text(item.text), bold: optional(item.bold) };
if (collection === "block_paragraph") return paragraph(item);
return {
type: "list",
ordered: item.ordered ? true : undefined,
items: (item.items ?? []).map(({ parts }: Item) =>
(parts ?? []).flatMap((part: Item) => [
paragraph(part),
...(part.sub_items?.length ? [{ type: "list" as const, items: part.sub_items.map((sub: Item) => [paragraph(sub)]) }] : []),
]),
),
};
}
export const loaders = {
services: directus("services", {
fields: ["*", ...files("image_1", "image_2")],
map: (item) => ({
slug: item.slug,
number: text(item.number),
title: item.title,
images: [src(item.image_1), src(item.image_2)],
description: text(item.description),
tools: (item.tools ?? []).map((tool: Item) => ({ label: text(tool.label), href: text(tool.href) })),
offer: highlighted(item.offer),
benefits: highlighted(item.benefits),
plans: (item.plans ?? []).map((plan: Item, i: number) => ({
id: plan.key || `plan-${i + 1}`,
label: text(plan.label),
price: text(plan.price),
note: text(plan.note),
included: text(plan.included).split("\n").filter(Boolean),
})),
}),
}),
projects: directus("projects", {
fields: [
"*",
...files("image", "overview_image", "process_image_1", "process_image_2", "result_image"),
"services.services_id.title",
"services.services_id.slug",
"services.services_id.status",
],
/* Nested lists stop at 100 items unless they ask for all. */
deep: { "deep[services][_sort]": "sort", "deep[services][_limit]": "-1" },
map: (item) => ({
slug: item.slug,
title: item.title,
year: text(item.year),
image: src(item.image),
/* A service set to Draft (or deleted) drops off its projects too. */
services: (item.services ?? []).flatMap(({ services_id: service }: Item) =>
service?.status === "published" ? [{ label: service.title, slug: service.slug }] : [],
),
summary: text(item.summary),
industry: text(item.industry),
overview: { text: text(item.overview_text), image: src(item.overview_image) },
process: { text: text(item.process_text), images: [src(item.process_image_1), src(item.process_image_2)] },
result: { items: highlighted(item.result_items), image: src(item.result_image) },
}),
}),
posts: directus("posts", {
fields: ["*", ...files("image"), "body.collection", "body.item:block_heading.*", "body.item:block_paragraph.*", "body.item:block_list.*"],
deep: { "deep[body][_sort]": "sort", "deep[body][_limit]": "-1" },
map: (item) => ({
slug: item.slug,
title: item.title,
excerpt: text(item.excerpt),
image: src(item.image),
category: text(item.category),
date: String(item.date).slice(0, 10),
body: (item.body ?? []).map(block),
}),
}),
careers: directus("careers", {
fields: ["*"],
map: (item) => ({
slug: item.slug,
title: item.title,
summary: text(item.summary),
responsibilities: lines(item.responsibilities),
requirements: lines(item.requirements),
benefits: lines(item.benefits),
}),
}),
team: directus("team", {
key: "name",
fields: ["*", ...files("photo")],
map: (item) => ({ name: item.name, role: text(item.role), email: text(item.email), photo: src(item.photo) }),
}),
testimonials: directus("testimonials", {
key: "name",
fields: ["*", ...files("photo", "logo")],
map: (item) => ({
quote: item.quote,
name: item.name,
company: text(item.company),
service: text(item.service),
photo: src(item.photo),
logo: src(item.logo),
}),
}),
};

The collections. Replace nitip-astro/src/content.config.ts — the schemas stay, only the loaders change:

nitip-astro/src/content.config.ts
/*
* Collections: loaded from Directus (src/lib/directus.ts) and validated with the schemas in src/content/schemas.ts.
* Items are keyed by `slug` (team and testimonials by name); `position` keeps the order set in the admin, which
* getCollection doesn't (src/lib/content.ts sorts by it).
*/
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import {
careerSchema,
postSchema,
projectSchema,
serviceSchema,
teamMemberSchema,
testimonialSchema,
} from "./content/schemas";
import { loaders } from "./lib/directus";
const position = { position: z.number().optional() };
export const collections = {
projects: defineCollection({ loader: loaders.projects, schema: projectSchema.extend(position) }),
services: defineCollection({ loader: loaders.services, schema: serviceSchema.extend(position) }),
posts: defineCollection({ loader: loaders.posts, schema: postSchema.extend(position) }),
careers: defineCollection({ loader: loaders.careers, schema: careerSchema.extend(position) }),
team: defineCollection({ loader: loaders.team, schema: teamMemberSchema.extend(position) }),
testimonials: defineCollection({ loader: loaders.testimonials, schema: testimonialSchema.extend(position) }),
};

Instant updates while you work. Create nitip-astro/src/integrations/cms-refresh.ts, which lets Directus tell the dev server to reload the collections:

nitip-astro/src/integrations/cms-refresh.ts
/*
* Dev only: POST /_refresh reloads the collections from the CMS, so a save in Directus shows on the next page load
* without restarting `npm run dev`. Directus' Flow calls it. A build reads the CMS anyway, so nothing is added there.
*/
import type { AstroIntegration } from "astro";
export default function cmsRefresh(): AstroIntegration {
return {
name: "cms-refresh",
hooks: {
"astro:server:setup": ({ server, refreshContent, logger }) => {
server.middlewares.use("/_refresh", async (req, res) => {
if (req.method !== "POST" || !refreshContent) {
res.statusCode = 405;
res.end();
return;
}
try {
await refreshContent({});
logger.info("Collections reloaded from the CMS.");
res.end("Refreshed");
} catch (error) {
/* Usually an entry the schemas reject; the message names it. */
logger.error(`Couldn't reload the collections: ${error instanceof Error ? error.message : error}`);
res.statusCode = 500;
res.end("Refresh failed");
}
});
},
},
};
}

astro.config.mjs — add the integration, allow images from Directus so Astro can download and resize them, and let Directus reach the dev server from Docker:

nitip-astro/astro.config.mjs
// @ts-check
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, fontProviders } from "astro/config";
import { loadEnv } from "vite";
import cmsRefresh from "./src/integrations/cms-refresh";
import forms from "./src/integrations/forms";
import siteContent from "./src/content/site.json" with { type: "json" };
const { PUBLIC_FORM_MODE } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
const { PUBLIC_FORM_MODE, DIRECTUS_URL = "http://localhost:8055" } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
export default defineConfig({
// …
/* PUBLIC_FORM_MODE=server adds the form route; see src/lib/forms/README.md. */
integrations: [forms(PUBLIC_FORM_MODE)],
integrations: [forms(PUBLIC_FORM_MODE), cmsRefresh()],
/* Images uploaded in Directus: Astro downloads, resizes and converts them when it builds. */
image: { domains: [new URL(DIRECTUS_URL).hostname] },
/* While you work, Directus (in Docker) calls the dev server at host.docker.internal: listen beyond localhost, and
answer to that name. */
server: { host: true, allowedHosts: ["host.docker.internal"] },

The server line matters only for npm run dev and npm run preview: Directus runs in Docker, so it reaches your machine at host.docker.internal, which Astro’s dev server otherwise neither listens on nor answers to. While the dev server runs, other devices on your network can open it too.

In Directus, open Flows — the lightning-bolt icon in the module bar on the left — and click Create Flow. Name it Rebuild the site, and set the trigger:

Field Value
Trigger Event Hook
Type Action (Non-Blocking)
Scope items.create, items.update, items.delete and items.sort
Collections Services, Projects, Posts, Careers, Team, Testimonials

Save, then add an operation after the trigger — Webhook / Request URL:

Field Value
Method POST
URL http://host.docker.internal:4321/_refresh

Save the flow. The six collections are enough: a post’s blocks and a project’s services are saved with their post or project, so a save calls the URL once. items.sort matters too: dragging items into a new order doesn’t count as an update.

Start the site in nitip-astro:

Terminal window
cd ../nitip-astro
npm run dev
  1. Open localhost:4321 — the site looks exactly as before, now read from Directus.
  2. In Directus’ Content, open Posts, pick one, change the title and save. The site’s terminal logs Collections reloaded from the CMS; reload the post and the new title is there.
  3. Set a post’s Status to Draft and save: it leaves the site. Back to Published, and it returns.
  4. Drag a post to another place in the list and reload the blog page: the order follows.

Then build the static site from Directus:

Terminal window
npm run build
npm run preview

dist/ now holds the whole site, with Directus’ content and images baked in — the same plain files as before.

  • Page copy — headings, intros, the About text, button labels — is in src/content/pages/, and the site details (name, contact, social links) in src/content/site.json. They change rarely, so they stay in the repository. Move them to a Directus singleton later if editors need them.
  • src/content/collections/ keeps the demo JSON for the import script. The site no longer reads it.
  • The style guide page (/style-guide) shows the first project, service and post from Directus, so keep at least one of each published — or delete src/pages/style-guide.astro if you don’t need it.
  • The contact and newsletter forms work as before (Forms).

There are two things to host now: Directus and the site.

  1. Directus runs anywhere Docker does. For production, use Postgres or MySQL instead of SQLite and a storage adapter (S3 and others) for the uploads, and set PUBLIC_URL to Directus’ public address — see Directus’ self-hosting and file storage docs. To set it up, point PUBLIC_URL and ADMIN_TOKEN in nitip-directus/.env at it and run step 2 from your machine — and step 3 too, if the demo content should come along. A schema snapshot would move the collections, but not the Site user, its access or the Flow.
  2. The site deploys as before (Publish), with two environment variables: DIRECTUS_URL (Directus’ public address) and DIRECTUS_TOKEN. The build fetches the content and images from it.
  3. Publishing: create a deploy hook for the site on its host (a URL that starts a build), then create the Flow from step 5 in the production Directus with that URL. Every save then rebuilds the site, live a minute or two later. Several saves in a row start several builds; hosts queue them.

We tested every step above locally, including the static build from Directus; hosting follows Directus’ and your host’s own docs, so check them for the details. Keep the secrets out of Git (.env in both folders).

Couldn't load projects from Directus at http://localhost:8055 (no answer) (or another collection). The site reads Directus while it starts and builds. Start it (docker compose up -d in nitip-directus), or fix DIRECTUS_URL in nitip-astro/.env. With 401 or 403 instead, the key doesn’t work: DIRECTUS_TOKEN must be SITE_TOKEN from nitip-directus/.env, and the setup script from step 2 must have run.

Directus stops at startup with FAILED_VALIDATION on email. ADMIN_EMAIL needs a real domain. Fix it, then stop Directus, empty the database folder (keep the folder itself) and start again.

Directus stops with SQLITE_CANTOPEN (Linux). Directus runs as user 1000 and can’t write to the folders: run sudo chown -R 1000:1000 database uploads and start it again.

A save doesn’t show in development. Open the Flow and check its Logs. A connection error means Directus can’t reach the dev server: check the server line from step 4 and the port in the Flow’s URL (the one npm run dev prints). A 403 means allowedHosts is missing host.docker.internal. Restarting the dev server also reloads everything.

A new order doesn’t show. The Flow’s scope is missing items.sort.

Remote image … is not allowed by your image configuration. astro.config.mjs is missing the image line from step 4, or DIRECTUS_URL points to another host than the images.

FailedToFetchRemoteImageDimensions while building. To read each image’s size, Astro fetches it from Directus and closes the connection after the first bytes, so a build opens hundreds of new connections; on a slow network one can miss Node’s 10-second limit. We saw it with Sanity’s image CDN, and the fix from that guide works here too — in astro.config.mjs, share a few connections with a longer limit (npm install -D undici first):

import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 8, connect: { timeout: 60_000 } }));

Another astro dev server is already running. Astro 7 runs one dev server per project: stop the old one with npx astro dev stop, or replace it with npm run dev -- --force.

Couldn't reload the collections in the dev server’s terminal, or the build stops on an entry. The schemas check every entry, and the message names the collection, the entry and the field — often an image deleted from the File Library while an item still uses it. Until it’s fixed, the dev server lists only the items before it in that collection. Choose another image, save, and the site reloads.

The seed says the database is already seeded. It only runs on an empty database. To start over, stop Directus, empty the database and uploads folders (keep the folders), start it, run steps 2 and 3 again and recreate the Flow from step 5 — it lived in the database too.

Still stuck? Contact support with the step and the error message.