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

LayerTechRole
PresentationNext.js 16 (App Router, RSC), React 19, TypeScript, Tailwind CSSServer-rendered marketing pages, SEO primitives, i18n routing
UI primitivesshadcn/ui (Radix under the hood), custom sectionsAccessible components, consistent design tokens
i18nnext-intl middlewareEN / FR / ES locale routing, translated UI strings
Data fetchNative fetch() → Directus /graphqlUnauthenticated Public-role reads, Next.js cache with 60s revalidation
CMSDirectus 11 + @directus-labs/seo-pluginHeadless content, structured SEO editor, role permissions
DatabasePostgreSQL 16 + PostGISContent + translation rows
CacheRedis 7Directus cache + rate limiting
RuntimeDocker ComposeReproducible local + production stack
Email (optional)ResendContact 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)

  1. Browser hits /services (or /fr/services, /es/services).
  2. next-intl middleware resolves the locale and rewrites to the [locale]/services/page.tsx segment.
  3. The server component calls a fetcher in frontend/src/lib/directus.ts: e.g. getServices(locale).
  4. The fetcher runs a GraphQL query against NEXT_PUBLIC_DIRECTUS_URL + /graphql with no token (Public role has read access).
  5. The response is flattened: nested { translations: [{ languages_code: { code }, ... }] } is reduced to a flat { title, description, ... } for the locale.
  6. 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.
  7. Next.js caches the response for 60 seconds: CMS edits propagate without a rebuild.
  8. 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:

SystemWhat it handlesUsed here?
languages user collection + *_translations child tablesPer-row content translations: blog title in EN/FR/ESYes
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:

  1. Edit config/brand.ts (name, contact, social, SEO defaults).
  2. Edit config/routes.ts if you add or rename top-level pages.
  3. Sign into Directus and replace seed content with real copy.
  4. 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