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

APIs

Ops & extending

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

  1. 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).
  2. Run bootstrap: idempotent, so it only creates the new field:
    docker compose run --rm directus-init
    
  3. Seed the field if you want default content: edit seed.mjs and re-run the init sidecar.
  4. Extend the GraphQL fetcher in lib/directus.ts to select the new field.
  5. Extend the TypeScript type in frontend/src/types/ so the rest of the frontend is type-safe.
  6. Render it in the relevant section component.

Adding a new collection

Same flow, one level up:

  1. In bootstrap.mjs, add the collection definition + its translations child + the relation + the Public-role permission.
  2. Run docker compose run --rm directus-init.
  3. Verify in the admin that the collection shows up in the correct group with the right icon and color.
  4. Write a seeder block in seed.mjs.
  5. Add a fetcher in lib/directus.ts.
  6. 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:

RoleCreated byWhat it can do
AdminDirectus on first bootEverything
Publicbootstrap.mjsRead 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_dump the Postgres container (see Hosting & Deployment).
  • Uploads (directus_uploads volume) → tar the volume; these are real user data, not reproducible.

For structured schema diffs when upgrading Directus itself, see the Directus schema migration guide.

Troubleshooting

SymptomLikely causeFix
GraphQL returns empty array for a collectionMissing Public-read permissionAdd it in bootstrap.mjs, re-run init
New field doesn't appear in GraphQLDirectus cached the schemaRestart Directus: docker compose restart directus
Translations appear as nullWrong locale code in query filterCheck the languages collection: codes must match (en, fr, es)
seo field shows a raw JSON textareaSEO plugin didn't mountConfirm the Dockerfile vendored it; check directus/extensions/ inside the container
directus-init re-runs and prints only [skip]Expected: the scripts are idempotentThis is correct, nothing to fix

Next steps