Architecture: How SaaSForge Agency Works
SaaSForge Agency is a two-service boilerplate: a Next.js 16 App Router frontend that consumes a self-hosted Directus 11 CMS over GraphQL. Everything: containers, schema, seed data, permissions, and the SEO plugin: is provisioned from code, so a fresh clone boots into a fully editable marketing site with one command.
This page maps the stack end-to-end so you know where each concern lives before you start editing.
The stack at a glance
| Layer | Tech | Role |
|---|---|---|
| Presentation | Next.js 16 (App Router, RSC), React 19, TypeScript, Tailwind CSS | Server-rendered marketing pages, SEO primitives, i18n routing |
| UI primitives | shadcn/ui (Radix under the hood), custom sections | Accessible components, consistent design tokens |
| i18n | next-intl middleware | EN / FR / ES locale routing, translated UI strings |
| Data fetch | Native fetch() → Directus /graphql | Unauthenticated Public-role reads, Next.js cache with 60s revalidation |
| CMS | Directus 11 + @directus-labs/seo-plugin | Headless content, structured SEO editor, role permissions |
| Database | PostgreSQL 16 + PostGIS | Content + translation rows |
| Cache | Redis 7 | Directus cache + rate limiting |
| Runtime | Docker Compose | Reproducible local + production stack |
| Email (optional) | Resend | Contact form delivery |
Repository layout
saasforge-agency/
├── docker-compose.yml # Full stack: frontend + directus + postgres + redis + init
├── .env.example # Baseline secrets for local + prod
├── directus/
│ ├── Dockerfile # Bakes @directus-labs/seo-plugin into the image
│ ├── scripts/
│ │ ├── bootstrap.mjs # Creates 18 collections, fields, relations, permissions
│ │ └── seed.mjs # Seeds EN/FR/ES content (idempotent)
│ ├── extensions/ # Vendored Directus extensions
│ └── uploads/ # Uploaded media (back this up)
└── frontend/
├── src/
│ ├── app/ # App Router: [locale]/ segment drives i18n routing
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── sections/ # Hero, Services, Pricing, FAQ, CTA, etc.
│ │ ├── layout/ # Header, Footer
│ │ └── shared/ # BookingCTA, CookieConsent, Analytics, ChatWidget
│ ├── config/
│ │ ├── brand.ts # Brand name, contact, social, trust metrics
│ │ ├── routes.ts # Central route definitions
│ │ ├── i18n.ts # Locale list + default
│ │ └── ui/ # Fallback copy if Directus is offline
│ ├── lib/
│ │ └── directus.ts # Typed GraphQL fetchers + response flatteners
│ ├── messages/ # next-intl JSON per locale (en.json, fr.json, es.json)
│ └── types/ # TypeScript types (incl. generated Directus shapes)
└── tailwind.config.ts
Request lifecycle (server render)
- Browser hits
/services(or/fr/services,/es/services). next-intlmiddleware resolves the locale and rewrites to the[locale]/services/page.tsxsegment.- The server component calls a fetcher in
frontend/src/lib/directus.ts: e.g.getServices(locale). - The fetcher runs a GraphQL query against
NEXT_PUBLIC_DIRECTUS_URL + /graphqlwith no token (Public role has read access). - The response is flattened: nested
{ translations: [{ languages_code: { code }, ... }] }is reduced to a flat{ title, description, ... }for the locale. - If the request fails or returns empty, the UI falls back to static copy in
frontend/src/config/ui/so the site never renders a broken state. - Next.js caches the response for 60 seconds: CMS edits propagate without a rebuild.
- HTML is streamed back with canonical URL, hreflang alternates, and JSON-LD baked into the
<head>.
Content model: two translation systems, one job
Directus ships two translation mechanisms. This template uses one of them; do not confuse them:
| System | What it handles | Used here? |
|---|---|---|
languages user collection + *_translations child tables | Per-row content translations: blog title in EN/FR/ES | Yes |
directus_translations (/admin/settings/translations) | UI label string keys referenced via $t: | No: leave empty |
Every content-bearing collection (e.g. services) has a sibling services_translations that stores one row per language. The bootstrap script wires the relation and seeds all three locales.
Config-first philosophy
Everything brand-specific lives in a handful of TypeScript files under frontend/src/config/: no scattered constants. Rebranding a fresh clone for a new client is:
- Edit
config/brand.ts(name, contact, social, SEO defaults). - Edit
config/routes.tsif you add or rename top-level pages. - Sign into Directus and replace seed content with real copy.
- Swap the logo asset in
frontend/public/.
No code changes, no template variables, no find-and-replace.
What is intentionally NOT in this boilerplate
Agency is a marketing site: see Implementation Status. If you need auth, subscriptions, or a dashboard, SaaSForge Core is the right base.
Next steps
- Quick Start: boot the stack in 5 minutes.
- UI Components: the design system and section library.
- Working with Directus: GraphQL patterns and CMS internals.