What We Built After Moving Away from a Headless CMS



What We Built After Moving Away from a Headless CMS
In a previous article, we explained why a headless CMS stopped working for the kind of applications we build.
The problem was not the tool. It was a mismatch between what the system is designed for and what we need. We build applications, not content platforms. So we changed the architecture.
The shift
The key decision was straightforward: We stopped treating the CMS as a backend.
Instead, we moved to a conventional backend architecture where we have full control over data access, business logic, and permissions.
Our backend stack is based on NestJS, TypeORM, and PostgreSQL.
This gives us explicit control over how data is queried, how logic is structured, and how performance behaves under load.
A shared foundation across all backends
Also, we wanted to avoid rebuilding the same infrastructure concerns in every project.
Instead of starting each backend from scratch, we built a shared library that all services use.
It bundles the concerns every application needs:
- TypeORM configuration and database setup
- OpenID Connect support for authentication
- Authorization logic
- Notification handling
- Email delivery
- CI/CD configuration
- and further cross-cutting concerns
This keeps individual services focused on business logic instead of boilerplate code and infrastructure.
The result is a consistent baseline across all backends, without reinventing the same patterns.
Working with NestJS and TypeORM, shared data model
With NestJS, we structure applications around modules, services, and controllers.
Database access is handled explicitly through repositories and the TypeORM entity manager. We do not always write raw queries, but we can when we need to optimize. We also design the database schema ourselves instead of relying on generated structures. This gives us precise control over how data is fetched, how relations behave, and how performance scales.
We use TypeORM to map entities to database tables and manage persistence. Entities are shared between backend and frontend, which keeps types consistent across the system. In practice, we maintain a single TypeScript data model, including TypeORM-annotated entities, that is used across both layers.
For simple use cases, we rely on generic CRUD services and controllers. For anything more complex, we implement explicit services that encapsulate data access logic.
This keeps database interactions predictable and localized.
Authentication and authorization
Authentication is handled through OpenID Connect.
Instead of adapting to a CMS-specific permission model, we define authorization rules in our own backend, based on user context, roles, and data.
The shared library provides an authentication module and guards that integrate directly into the application layer.
This makes authentication and authorization first-class concerns instead of workarounds.
Making data editable for business users
Moving away from a headless CMS means losing the built-in editing experience for business users.
To address this, we use NocoDB as a lightweight layer when direct data editing is required.
It does not offer the same level of integration or comfort as a CMS admin UI, but it is sufficient for most use cases where power users need access.
When similar edits are repetitively made through NocoDB, we implement proper UI support for these activities.
This keeps the backend architecture clean while still enabling controlled data editing.
Developer experience
We also optimized for speed.
New applications can be generated using our Turborepo-based generators. This scaffolds a full project with backend, frontend, types, and shared utilities in place.
We keep the fast start we had with a headless CMS, but without giving up architectural control.
What improved
Moving to this setup changed how our systems behave.
- Data access is explicit and predictable
- Performance can be optimized at the query level
- Authorization is implemented directly and explicitly and reflects real application requirements
- We catch more errors at compile time due to first-class TypeScript support in our NestJS backends
- Compared to the often inconsistent TypeScript experience in Strapi, this makes systems more predictable and safer to evolve
- Upgrades are straightforward and under our control, without being blocked by framework-level migration paths
- We are no longer locked into specific CMS versions or forced to delay upgrades due to breaking changes
The overall complexity did not disappear, but it is now where it belongs.
What got worse
This approach is not free.
- There is more upfront work compared to a headless CMS
- You lose some of the instant scaffolding convenience, especially editor support
- Data modeling with business stakeholders is less direct. Instead of configuring content types in a CMS UI, we use a diagramming approach first and then implement it in code
These trade-offs are predictable and do not grow uncontrollably over time.
What we would do from day one
If we started again, we would make the separation earlier.
A CMS is a good solution for content. An application backend is something else.
Trying to merge both into a single system works for a while and is great for bootstrapping new apps, but it does not scale with complexity.
The earlier you treat data access, business logic, and permissions as first-class concerns, the fewer workarounds you need later.