Skip to content
Sign inJoin waitlist

Connect Directus to a Next.js template

Directus puts an admin and an API on top of a SQL database you own: editors get forms, lists and a media library, the site gets a REST API. You host it yourself — this guide runs it in Docker — and connects it to the Nitip Next.js edition step by step: at the end, editors manage services, projects, posts, jobs, team and testimonials in Directus, and the site updates when they save.

We followed every step below on a fresh copy of Nitip 1.0.0 with Directus 12.4 and Next.js 16.3, on a local machine — including the Flow: Directus calls the site after a change, and the pages update. Every other Framebrick Next.js template works the same way: it reads its collections through the same src/lib/content.ts, so only the collections and fields differ — its Using a headless CMS page lists them.

my-site/
├── nitip-nextjs/ the website: this template
└── nitip-directus/ Directus in Docker: the admin at localhost:8055/admin and the API
  • The site reads Directus’ REST API as a Site user with a read-only key, and asks for published items only. Drafts stay off the site, and the key never leaves the server.
  • When an editor creates, changes, deletes or reorders an item, a Directus Flow calls the site’s /api/revalidate, and every page is rebuilt from Directus on its next visit.
  • Images are uploaded to Directus, served publicly like any site’s images, and resized by next/image.
  • The Nitip Next.js edition, unzipped into a folder of its own (my-site/nitip-nextjs), with npm install done and npm run dev working (Install and run).
  • Docker — Directus’ recommended way to run it.
  • Node.js 20.9 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 the site's .env.local repeats it.
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-nextjs/src/content/types.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 type in the Next.js project's src/content/types.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/types.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 refresh the site’s pages.
  • 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-nextjs. Create nitip-directus/scripts/seed.mts:

nitip-directus/scripts/seed.mts
/*
* Copies the demo content of the Next.js site into Directus, published, once — with Directus up:
* npx tsx --env-file=.env scripts/seed.mts
* Reads <site>/src/content/collections and uploads the images from <site>/public/images. It stops if services already
* exist.
*/
import fs from "node:fs";
import path from "node:path";
import { careers } from "../../nitip-nextjs/src/content/collections/careers";
import { posts } from "../../nitip-nextjs/src/content/collections/posts";
import { projects } from "../../nitip-nextjs/src/content/collections/projects";
import { services } from "../../nitip-nextjs/src/content/collections/services";
import { team } from "../../nitip-nextjs/src/content/collections/team";
import { testimonials } from "../../nitip-nextjs/src/content/collections/testimonials";
import type { PostBlock } from "../../nitip-nextjs/src/content/types";
/* The Next.js project, in the folder next to this one. */
const site = path.resolve("../nitip-nextjs");
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" };
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" → 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, "public", 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 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.

Everything in this step happens in nitip-nextjs.

.env.local — where Directus runs, the Site user’s key, and a secret for the Flow (step 5), any long random string:

nitip-nextjs/.env.local
# 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
# Any long random string; the Flow sends it (step 5).
DIRECTUS_REVALIDATE_SECRET=replace-with-a-long-random-string

The client. Create nitip-nextjs/src/lib/directus.ts:

nitip-nextjs/src/lib/directus.ts
/*
* Reads Directus' REST API with the site's key (DIRECTUS_TOKEN — the "Site" user the setup script creates). That user
* can read drafts too, so every request asks for published items. The key stays on the server: no page sends it to the
* browser.
*/
const url = (process.env.DIRECTUS_URL ?? "http://localhost:8055").replace(/\/+$/, "");
/** Every published item of a collection, in the admin's drag-and-drop order. */
export async function items<T>(collection: string, fields: string[], deep: Record<string, string> = {}): Promise<T[]> {
const search = new URLSearchParams({ fields: fields.join(","), sort: "sort", limit: "-1", "filter[status][_eq]": "published", ...deep });
const response = await fetch(`${url}/items/${collection}?${search}`, { headers: { Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}` } });
if (!response.ok) throw new Error(`Directus ${collection}: ${response.status} ${await response.text()}`);
return (await response.json()).data;
}
export type File = { id: string; filename_download: string } | null;
/** The fields a file needs for its address (see asset). */
export const files = (...names: string[]) => names.flatMap((name) => [`${name}.id`, `${name}.filename_download`]);
/** A file → the public address Directus serves it at, ending with its file name, so next/image sees an SVG as one. */
export const asset = (file: File | undefined) => (file ? `${url}/assets/${file.id}/${encodeURIComponent(file.filename_download)}` : "");

The content functions. Replace nitip-nextjs/src/lib/content.ts. The function names and return types stay the same, so no page or component changes; each one reads a collection with the fields it needs and maps it to the template’s types:

nitip-nextjs/src/lib/content.ts
/*
* Where pages get their collections — now from Directus (src/lib/directus.ts). Each function reads one collection, in
* the order editors set in the admin, and maps the items to the types in src/content/types.ts, so sections and
* design-system components don't change.
*
* `cache` makes each call run once per request, even when metadata and the page ask for the same entry.
*/
import { cache } from "react";
import type { Career, Highlighted, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "@/content/types";
import { asset, type File, files, items } from "@/lib/directus";
type Text = { highlight?: string | null; text?: string | null };
type Line = { text: string };
const highlighted = (list: Text[] | null): Highlighted[] => (list ?? []).map((item) => ({ highlight: item.highlight || undefined, text: item.text ?? "" }));
const lines = (list: Line[] | null) => (list ?? []).map((item) => item.text);
export const getServices = cache(async (): Promise<Service[]> => {
type Item = Omit<Service, "images" | "offer" | "benefits" | "plans"> & {
image_1: File;
image_2: File;
offer: Text[] | null;
benefits: Text[] | null;
plans: { key: string; label: string; price: string; note: string; included: string }[] | null;
};
const list = await items<Item>("services", ["*", ...files("image_1", "image_2")]);
return list.map((item) => ({
slug: item.slug,
number: item.number ?? "",
title: item.title,
images: [asset(item.image_1), asset(item.image_2)],
description: item.description ?? "",
tools: (item.tools ?? []).map(({ label, href }) => ({ label: label ?? "", href: href ?? "" })),
offer: highlighted(item.offer),
benefits: highlighted(item.benefits),
plans: (item.plans ?? []).map((plan, i) => ({
id: plan.key || `plan-${i + 1}`,
label: plan.label ?? "",
price: plan.price ?? "",
note: plan.note ?? "",
included: (plan.included ?? "").split("\n").filter(Boolean),
})),
}));
});
export const getService = cache(async (slug: string) => (await getServices()).find((item) => item.slug === slug));
export const getProjects = cache(async (): Promise<Project[]> => {
type Item = Omit<Project, "image" | "services" | "overview" | "process" | "result"> & {
image: File;
services: { services_id: { title: string; slug: string; status: string } | null }[];
overview_text: string;
overview_image: File;
process_text: string;
process_image_1: File;
process_image_2: File;
result_items: Text[] | null;
result_image: File;
};
const list = await items<Item>("projects", ["*", ...files("image", "overview_image", "process_image_1", "process_image_2", "result_image"), "services.services_id.title", "services.services_id.slug", "services.services_id.status"], { "deep[services][_sort]": "sort", "deep[services][_limit]": "-1" });
return list.map((item) => ({
slug: item.slug,
title: item.title,
year: item.year ?? "",
image: asset(item.image),
/* A service set to Draft (or deleted) drops off its projects too. */
services: item.services.flatMap(({ services_id: service }) => (service?.status === "published" ? [{ label: service.title, slug: service.slug }] : [])),
summary: item.summary ?? "",
industry: item.industry ?? "",
overview: { text: item.overview_text ?? "", image: asset(item.overview_image) },
process: { text: item.process_text ?? "", images: [asset(item.process_image_1), asset(item.process_image_2)] },
result: { items: highlighted(item.result_items), image: asset(item.result_image) },
}));
});
export const getProject = cache(async (slug: string) => (await getProjects()).find((item) => item.slug === slug));
/** The project after this one, back to the first after the last ("Next Project" on a project page). */
export const getNextProject = cache(async (slug: string) => {
const projects = await getProjects();
return projects[(projects.findIndex((item) => item.slug === slug) + 1) % projects.length];
});
/* A post body block from the builder → the blocks the template renders. A list item's parts are paragraphs, each with
the bulleted sub-list that follows it. */
type Part = Text & { sub_items?: Text[] | null };
type BodyBlock = { collection: string; item: Text & { bold?: string | null; ordered?: boolean; items?: { parts: Part[] | null }[] | null } };
const paragraph = (item: Text): PostBlock => ({ type: "paragraph", highlight: item.highlight || undefined, text: item.text ?? "" });
function block({ collection, item }: BodyBlock): PostBlock {
if (collection === "block_heading") return { type: "heading", text: item.text ?? "", bold: item.bold || undefined };
if (collection === "block_paragraph") return paragraph(item);
return {
type: "list",
ordered: item.ordered ? true : undefined,
items: (item.items ?? []).map(({ parts }) =>
(parts ?? []).flatMap((part) => [paragraph(part), ...(part.sub_items?.length ? [{ type: "list" as const, items: part.sub_items.map((sub) => [paragraph(sub)]) }] : [])]),
),
};
}
export const getPosts = cache(async (): Promise<Post[]> => {
type Item = Omit<Post, "image" | "body"> & { image: File; body: BodyBlock[] };
const list = await items<Item>(
"posts",
["*", ...files("image"), "body.collection", "body.item:block_heading.*", "body.item:block_paragraph.*", "body.item:block_list.*"],
/* Nested lists stop at 100 items unless they ask for all. */
{ "deep[body][_sort]": "sort", "deep[body][_limit]": "-1" },
);
return list.map((item) => ({
slug: item.slug,
title: item.title,
excerpt: item.excerpt ?? "",
image: asset(item.image),
category: item.category ?? "",
date: item.date,
body: item.body.map(block),
}));
});
export const getPost = cache(async (slug: string) => (await getPosts()).find((item) => item.slug === slug));
export const getCareers = cache(async (): Promise<Career[]> => {
type Item = Omit<Career, "responsibilities" | "requirements" | "benefits"> & Record<"responsibilities" | "requirements" | "benefits", Line[] | null>;
const list = await items<Item>("careers", ["*"]);
return list.map((item) => ({
slug: item.slug,
title: item.title,
summary: item.summary ?? "",
responsibilities: lines(item.responsibilities),
requirements: lines(item.requirements),
benefits: lines(item.benefits),
}));
});
export const getCareer = cache(async (slug: string) => (await getCareers()).find((item) => item.slug === slug));
export const getTeam = cache(async (): Promise<TeamMember[]> =>
(await items<Omit<TeamMember, "photo"> & { photo: File }>("team", ["*", ...files("photo")])).map((item) => ({ name: item.name, role: item.role ?? "", email: item.email ?? "", photo: asset(item.photo) })),
);
export const getTestimonials = cache(async (): Promise<Testimonial[]> =>
(await items<Omit<Testimonial, "photo" | "logo"> & { photo: File; logo: File }>("testimonials", ["*", ...files("photo", "logo")])).map((item) => ({
quote: item.quote,
name: item.name,
company: item.company ?? "",
service: item.service ?? "",
photo: asset(item.photo),
logo: asset(item.logo),
})),
);

Images. Replace nitip-nextjs/next.config.ts, so next/image resizes the files Directus serves:

nitip-nextjs/next.config.ts
import type { NextConfig } from "next";
const directus = new URL(process.env.DIRECTUS_URL ?? "http://localhost:8055");
const nextConfig: NextConfig = {
images: {
/* Files uploaded in Directus; next/image resizes them like the local ones. */
remotePatterns: [{ protocol: directus.protocol.replace(":", "") as "http" | "https", hostname: directus.hostname, port: directus.port }],
/* Next.js won't fetch images from a private address; allow it only while Directus runs on this machine. */
dangerouslyAllowLocalIP: ["localhost", "127.0.0.1"].includes(directus.hostname),
},
};
export default nextConfig;

The second line matters only while Directus runs on your machine: Next.js refuses to fetch images from a private address unless you allow it, and the config allows it only for localhost.

Let new entries render. The pages in src/app/blog/[slug], careers/[slug], projects/[slug] and services/[slug] only allow the slugs that existed at build time. Allow new ones:

nitip-nextjs/src/app/blog/[slug]/page.tsx
/* Only the posts in the collection exist; any other slug is a 404. */
export const dynamicParams = false;
/* Entries published in the CMS after the build render on their first visit; unknown slugs are a 404. */
export const dynamicParams = true;

Create the route the Flow will call, nitip-nextjs/src/app/api/revalidate/route.ts:

nitip-nextjs/src/app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
/* Directus calls this after an item is created, changed or deleted (a Flow sending
"Authorization: Bearer <DIRECTUS_REVALIDATE_SECRET>"). Every page is then rebuilt from Directus on its next visit:
pages share content — a renamed service shows on project pages too. */
export async function POST(request: Request) {
const secret = process.env.DIRECTUS_REVALIDATE_SECRET;
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) return new Response("Unauthorized", { status: 401 });
revalidatePath("/", "layout");
return Response.json({ revalidated: true });
}

Then in Directus, open Flows — the lightning-bolt icon in the module bar on the left — and click Create Flow. Name it Revalidate 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, Block Heading, Block Paragraph, Block List, Projects Services, Posts Body

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

Field Value
Method POST
URL http://host.docker.internal:3000/api/revalidate
Headers Authorization = Bearer <your DIRECTUS_REVALIDATE_SECRET>

Save the flow. Directus runs in Docker, so it reaches the site on your machine at host.docker.internal (the extra_hosts line in docker-compose.yml makes that work on Linux too). items.sort matters: dragging items into a new order doesn’t count as an update, so without it the site keeps the old order.

Build and start the site — the Flow matters for the pre-rendered production pages:

Terminal window
cd ../nitip-nextjs
npm run build
npm start
  1. Open localhost:3000 — the site looks exactly as before, now read from Directus.
  2. In Directus’ Content, open Posts, pick one, change the title and save.
  3. Reload the post on the site (or the blog list, or the home page): the new title is there.
  4. Set a post’s Status to Draft and save: it leaves the site. Back to Published, and it returns.
  5. Drag a post to another place in the list and reload the blog page: the order follows.

With npm run dev every reload reads Directus, so you see changes without the Flow.

  • 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.ts. 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 data: the import script reads it, and the style guide page shows samples from it. The site’s pages no longer use 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 DIRECTUS_URL (Directus’ public address), DIRECTUS_TOKEN and DIRECTUS_REVALIDATE_SECRET in the host’s environment variables.
  3. The Flow — create it in the production Directus as in step 5, with the URL https://yourcompany.com/api/revalidate.

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

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.

Directus posts: 401 or 403. The site’s key doesn’t work: DIRECTUS_TOKEN in .env.local must be SITE_TOKEN from nitip-directus/.env, and the setup script from step 2 must have run. Restart the site after changing .env.local.

TypeError: fetch failed on every page or in npm run build. The site can’t reach Directus at DIRECTUS_URL: start it (docker compose up -d in nitip-directus), or on a host set DIRECTUS_URL to Directus’ public address — the build reads Directus too.

Images don’t load, and the terminal says the hostname resolved to private IP. next.config.ts is missing the dangerouslyAllowLocalIP line, or DIRECTUS_URL isn’t localhost.

A saved change doesn’t show with npm start. Open the Flow and check its Logs: a 401 means the header’s secret differs from DIRECTUS_REVALIDATE_SECRET; a connection error means the site isn’t running at the Flow’s URL.

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

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.