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.
How it fits together
Section titled “How it fits together”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.
Before you start
Section titled “Before you start”- The Nitip Next.js edition, unzipped into a folder of its own (
my-site/nitip-nextjs), withnpm installdone andnpm run devworking (Install and run). - Docker — Directus’ recommended way to run it.
- Node.js 20.9 or newer, and about an hour.
1. Start Directus
Section titled “1. Start Directus”In my-site, create the folder nitip-directus with two files. 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:
# 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-stringADMIN_EMAIL=you@yourcompany.comADMIN_PASSWORD=replace-with-a-strong-passwordADMIN_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-stringUse 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:
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"Then create the folders for the database and the uploads, and start Directus:
cd nitip-directusmkdir databasemkdir uploadsdocker compose up -dOpen 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.
2. Describe the content
Section titled “2. Describe the content”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:
/* * 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:
node --env-file=.env scripts/define-content.mjsA few choices worth knowing:
- Order. Each collection has a hidden
sortfield, 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.
3. Import the demo content
Section titled “3. Import the demo content”The import script reads the demo content straight from nitip-nextjs. Create 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):
npx tsx --env-file=.env scripts/seed.mtsIt 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. Read the pages from Directus
Section titled “4. Read the pages from Directus”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:
# 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-stringThe client. Create 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:
/* * 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:
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:
/* 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;5. Publish on save
Section titled “5. Publish on save”Create the route the Flow will call, 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.
6. Try it
Section titled “6. Try it”Build and start the site — the Flow matters for the pre-rendered production pages:
cd ../nitip-nextjsnpm run buildnpm start- Open localhost:3000 — the site looks exactly as before, now read from Directus.
- In Directus’ Content, open Posts, pick one, change the title and save.
- Reload the post on the site (or the blog list, or the home page): the new title is there.
- Set a post’s Status to Draft and save: it leaves the site. Back to Published, and it returns.
- 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.
What stays in the code
Section titled “What stays in the code”- Page copy — headings, intros, the About text, button labels — is in
src/content/pages/, and the site details (name, contact, social links) insrc/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).
Going live
Section titled “Going live”There are two things to host now: Directus and the site.
- 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_URLto Directus’ public address — see Directus’ self-hosting and file storage docs. To set it up, pointPUBLIC_URLandADMIN_TOKENinnitip-directus/.envat 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. - The site deploys as before (Publish), with
DIRECTUS_URL(Directus’ public address),DIRECTUS_TOKENandDIRECTUS_REVALIDATE_SECRETin the host’s environment variables. - 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).
Troubleshooting
Section titled “Troubleshooting”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.
Learn more
Section titled “Learn more”- Directus: relationships — the many-to-many and builder fields
- Directus: access control — policies, and roles for editors
- Directus: flows
- Use a headless CMS with a Next.js template — how every template’s content layer works, and the other CMS options