Somewhere between customer twenty and customer fifty, "we'll just add a
tenant_id column" stops being a plan and becomes a liability.
This article is about the three ways to do multi-tenant content, why one of
them is meaningfully safer, and what the common CMS pricing models do to a SaaS
margin when the customer count grows.
Almost everyone ends up at row-level, because the operational cost of the other
two is real and compounding. Which makes the last cell the whole question:
what stops the query that forgets the filter?
Not because your team is careless. Because of arithmetic.
A mature product has hundreds of queries. Some are in the reporting job nobody
has opened in a year. Some are in the admin tool written during an incident.
Some are in a module that a contractor added and a reviewer skimmed. Every one
of them has to remember WHERE tenant_id = $1.
The failure is silent and total. A missing filter does not throw. It returns
one customer's content to another, in a response that looks perfectly normal
until somebody notices their competitor's unpublished pricing page in a
dropdown.
PostgreSQL row-level security moves the boundary underneath the mistake.
You attach a policy to the table, set the tenant in the session, and the
database applies the predicate to every query — including the ones nobody
reviewed:
Now the query that forgot the filter returns zero rows instead of everyone
else's. The bug becomes a visible absence rather than an invisible leak — and
that difference is the whole argument.
corpusctl does this in the core, alongside auth and the module registry. Every
application query is still tenant-scoped explicitly; RLS is the second line
of defence, not the first. The point of a second line is that it is there on
the day the first one fails.
Tables are also hash-partitioned by tenant, which keeps one large customer from
degrading query plans for everyone else.
In a real SaaS, people belong to several customers. An agency user manages
three of your clients. A support engineer needs read access to one account for
a week. A contractor works on brand two and must not see brand one.
If your model is "user belongs to tenant", every one of these becomes a
duplicate account, and duplicate accounts become an audit problem.
corpusctl's model: a user carries a separate role in each space. Owner on
one, editor on another, invisible on a third. One identity, N memberships, and
the panel shows only the spaces the person actually belongs to.
There is one deliberate rule on top: the last owner of a space cannot be
demoted or removed. A space with no owner is a space nobody can administer —
the lock stays on the outside.
This is where the CMS choice stops being a technical decision.
Verified from vendor pricing pages on 17 August 2026:
Product
Unit
Price
100 customers
Strapi Cloud
Per project
from $35/mo
from $3,500/mo
Sanity
Per dataset above 2
$999/mo
prohibitive
Directus
Per instance / seat
$499/mo Team, +$50/seat
per-instance operations
Ghost(Pro)
Per publication, audience-scaled
from $18/mo yearly
100 subscriptions
corpusctl
None
Flat plan
unchanged
The pattern to notice: those units scale with your customer count, not with
your revenue per customer. A SaaS on a $29/month plan cannot absorb a $35/month
CMS line per customer. The arithmetic simply does not work, and it does not get
better with volume.
This is the case corpusctl was built for. Spaces are not a billing unit.
The second cost that scales with customers is traffic. If your CMS meters API
calls, then every customer's visitors are on your meter, and your busiest
customer is your most expensive one — regardless of what they pay you.
corpusctl's read path removes the meter by removing the call. Publishing writes
content to an immutable, hash-addressed file in object storage and refreshes
a manifest. The reader's client resolves the slug to a manifest shard locally,
then fetches that address from the nginx edge cache. The address is a content
hash, so it can never return different content — cacheable for thirty days with
no invalidation strategy.
Visitor traffic never reaches the API or PostgreSQL. Your write path is sized
for your editors; your read path is sized by your cache.
In a SaaS you will have customers who downgrade, lapse and come back. What the
CMS does at that moment is a product decision you inherit.
corpusctl makes every feature a module — media, search, webhooks, SEO,
GraphQL, import, analytics — with two states:
State
Data
disabled
Preserved. Re-enable and it resumes where it left off.
uninstalled
onUninstall clears it, with a double confirmation.
A plan downgrade sets modules to disabled. It never deletes data. That is
enforced in the codebase, not promised in a support article — which matters,
because the customer who comes back after three months expects their content to
be there, and they are right to.
Modules also may not import one another. A service registry and an event bus
are the only channels between them, and CI fails the build if that rule is
broken. The practical consequence: removing a feature cannot break the system,
so per-tenant feature sets are a configuration question rather than a fork.
#A checklist for evaluating any CMS for multi-tenant SaaS
Is a project a billing unit? What does customer 100 cost?
Where is isolation enforced — application, or database?
Can one user hold different roles in different tenants?
Are reads metered? By whom — you, or your customers' visitors?
What happens to data on downgrade?
Can you provision a tenant from the API, without a human in the panel?
Is there a per-tenant quota counter you can read and bill against?
Question one eliminates most candidates before you get to question two.
Draft mode, ISR, the App Router and the render layer. What actually matters when you wire a headless CMS to Next.js — and the three ways it goes wrong.