Building a publishing platform, Part 1: One repo, two apps
I spent much of the past year building a digital publication for geospatial content in Africa: articles, industry news, magazine issues, events and a partner directory. It has two moving parts: a public-facing Angular 21 SSR frontend, and a Strapi 5 headless CMS where editors do their work. This first note covers the decision I made before writing any feature code: putting both apps in a single Nx monorepo.
Why a monorepo at all?
A CMS-driven site lives and dies on the contract between the frontend and the content API. Keep them in separate repos and that contract exists only in your head. Rename a field in Strapi and the frontend finds out at runtime, in production, usually on a Sunday. In one repo, the contract becomes code:
platform/
├── apps/
│ ├── web/ # Angular 21 SSR frontend
│ ├── web-e2e/ # Playwright e2e tests
│ └── cms/ # Strapi 5 headless CMS
├── libs/
│ └── shared/interfaces/ # The contract lives here
└── tools/scripts/ # Seed & utility scripts
libs/shared/interfaces holds the TypeScript interfaces for every content
type — Article, Author, Event, Issue
and friends. Both apps import from it. Change an interface and the compiler tells you
every place on either side that needs to catch up. That single library has caught more
bugs than any test I have written for this project.
What Nx actually buys you
You could wire this up with plain npm workspaces, and for a two-app repo that is a fair choice. Nx earns its keep in three specific ways:
Task caching. Nx hashes the inputs of every task. If nothing in
apps/cms changed, nx build cms returns in milliseconds from
cache. CI on a content-type-only change never rebuilds the frontend.
The dependency graph. dependsOn: ["^build"] means building
web automatically builds shared/interfaces first. And
nx affected lets CI test and build only what a commit actually touched.
One toolchain. A single ESLint config, one TypeScript version and one set of generators across an Angular app and a Node-based CMS. Boring, in the best way.
The awkward part: Strapi in Nx
Strapi is not a first-class Nx citizen. There is no official plugin. My approach: treat
apps/cms as a mostly self-contained Strapi project and give it a thin
project.json whose targets just call Strapi's own CLI
(strapi develop, strapi build). Nx handles orchestration and
caching; Strapi never knows it lives in a monorepo. Resist the urge to make Strapi's
build "properly Nx-native." You will fight webpack configs for a weekend and gain
nothing.
SSR because search engines read the web
A publishing platform without server-side rendering is invisible. Angular Universal renders every article on the server, so crawlers and link previews get real HTML with real OpenGraph tags. Strapi data is fetched during server render, and Angular's transfer state means the browser does not refetch what the server already resolved.
Takeaways
Put the API contract in a shared library from day one. It is the whole point of the monorepo. Let Nx cache and orchestrate, but let Strapi be Strapi. And if you are building anything content-driven, SSR is not an optimization, it is table stakes.
Next in the series: Part 2 covers zero-downtime deploys with Traefik, Docker and GitHub Actions: how the whole stack ships to a single VPS with automatic TLS.