Working with Directus
SaaSForge Agency is tightly integrated with Directus 11: a headless CMS that gives you a Postgres-backed API (REST + GraphQL) with a generated admin UI. This page is the reference for everyone who needs to go deeper than "edit a row": adding fields, extending the schema, wiring GraphQL, managing permissions, and finding the right official doc when you get stuck.
Official Directus documentation: where to go for what
Bookmark these. The Directus docs are well-organized; most tasks have a dedicated page.
Core concepts
- Directus documentation home: canonical entry point.
- Data Studio overview: the admin UI you hit at
http://localhost:8055. - Data model guide: collections, fields, relations, interfaces.
- Content translations (i18n): exactly the pattern this boilerplate uses.
- Roles & permissions: Public vs. authenticated roles, field-level rules.
APIs
- GraphQL API reference: what the frontend uses.
- REST API reference: useful for scripts and ad-hoc tooling.
- Filter rules syntax: how
filter:works in queries. - Global query parameters:
fields,sort,limit,deep, etc.
Ops & extending
- Files & assets: uploads, transformations, presets.
- Flows (automation): event triggers, webhooks, logic.
- Extensions: custom interfaces, endpoints, hooks, layouts.
- Self-hosting overview: Docker, env vars, scaling.
- Configuration options: every
DIRECTUS_*andDB_*setting.
How this boilerplate talks to Directus
1. Everything is programmatic
directus/scripts/bootstrap.mjs uses the Directus REST API to create collections, fields, relations, and permissions. You do not design the schema by clicking in the admin: you edit the script.
Why: a commercial boilerplate must be reproducible. A buyer's clone must boot into the same schema without running through a setup wizard.
2. Frontend uses GraphQL, Public role, no token
frontend/src/lib/directus.ts posts raw GraphQL to NEXT_PUBLIC_DIRECTUS_URL + /graphql. The Public role (created by bootstrap.mjs) has read permissions on every content collection, so no auth header is needed.
3. Every content type has a translations child
Collection foo → translations child foo_translations → linked to languages by languages_code. The translations row owns per-locale fields (title, description, body, etc.). The parent row owns non-translatable fields (slug, sort, icon, seo).
Adding a field to an existing collection
- Edit
directus/scripts/bootstrap.mjs: find the section for the target collection and add your field object. Follow the same style used by existing fields (non-translatable on the parent; translatable on*_translations). - Run bootstrap: idempotent, so it only creates the new field:
docker compose run --rm directus-init - Seed the field if you want default content: edit
seed.mjsand re-run the init sidecar. - Extend the GraphQL fetcher in
lib/directus.tsto select the new field. - Extend the TypeScript type in
frontend/src/types/so the rest of the frontend is type-safe. - Render it in the relevant section component.
Adding a new collection
Same flow, one level up:
- In
bootstrap.mjs, add the collection definition + its translations child + the relation + the Public-role permission. - Run
docker compose run --rm directus-init. - Verify in the admin that the collection shows up in the correct group with the right icon and color.
- Write a seeder block in
seed.mjs. - Add a fetcher in
lib/directus.ts. - Add a type, a section or page, and a fallback in
config/ui/.
GraphQL query pattern used here
Queries target the translations pattern consistently:
query ($locale: String!) {
services(sort: ["sort"]) {
id
slug
icon
sort
seo
translations(
filter: { languages_code: { code: { _eq: $locale } } }
) {
languages_code { code }
title
description
features
}
}
}
The fetcher then flattens the single-entry translations array so the rest of the app sees { id, slug, title, description, features } instead of a nested shape. See flattenList in lib/directus.ts.
Filtering, sorting, pagination
All three are native GraphQL arguments on any collection query:
blog_posts(
filter: { status: { _eq: "published" } }
sort: ["-date_published"]
limit: 10
offset: 0
) { … }
See the Directus filter rules reference for the full operator list (_eq, _neq, _in, _contains, _between, …).
Permissions model
Only two roles matter for this template:
| Role | Created by | What it can do |
|---|---|---|
| Admin | Directus on first boot | Everything |
| Public | bootstrap.mjs | Read on every content collection; Create on form_submissions (for the contact form) |
If you add a new collection, you must also add a Public-read permission in bootstrap.mjs: otherwise the frontend GraphQL query returns empty.
SEO field: the pre-bundled plugin
Every page-like collection (pages, blog_posts, services, legal_pages, docs_pages, seo_landing_pages, portfolio_projects, case_studies) has a JSON field called seo rendered by @directus-labs/seo-plugin. You get a structured editor with live Search Preview, character-count hints, Open Graph fields, canonical URL, and robots controls.
The plugin is vendored into the custom Directus image at directus/Dockerfile, so it's available immediately on first boot: no npm install step in the admin.
File uploads
Uploads (logos, hero images, blog cover images) are persisted in the directus_uploads Docker volume and served by Directus at /assets/:id. See the Directus files guide for built-in image transformations (resize, crop, quality) you can request via query params.
Extensions
The directus/extensions/ folder is mounted into the container. Drop any Directus extension there (interfaces, displays, endpoints, hooks, flows) and restart Directus. The extensions documentation covers the CLI scaffolding and the different extension types.
Backups and migrations
- Schema is re-creatable from
bootstrap.mjs→ no backup needed. - Content (Postgres rows) →
pg_dumpthe Postgres container (see Hosting & Deployment). - Uploads (
directus_uploadsvolume) →tarthe volume; these are real user data, not reproducible.
For structured schema diffs when upgrading Directus itself, see the Directus schema migration guide.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| GraphQL returns empty array for a collection | Missing Public-read permission | Add it in bootstrap.mjs, re-run init |
| New field doesn't appear in GraphQL | Directus cached the schema | Restart Directus: docker compose restart directus |
Translations appear as null | Wrong locale code in query filter | Check the languages collection: codes must match (en, fr, es) |
seo field shows a raw JSON textarea | SEO plugin didn't mount | Confirm the Dockerfile vendored it; check directus/extensions/ inside the container |
directus-init re-runs and prints only [skip] | Expected: the scripts are idempotent | This is correct, nothing to fix |
Next steps
- Directus CMS Setup: the one-command bootstrap flow.
- Content Management: day-to-day editor workflow.
- UI Components: rendering Directus data on the frontend.