Sanity + Supabase: The Hybrid CMS Pattern for B2B Content Products
I picked two databases instead of one. Non-technical editors now ship content 3x faster. Here's the architecture — and when it's the wrong choice.
The short version
Amal Najib needs halal counselors — not developers — to author content weekly. A pure Supabase setup puts me in the loop for every change. The fix: Sanity owns editorial content, Supabase owns transactional data, they never JOIN, they link by document ID and meet in React. Works because the data is naturally partitioned. Collapses to one store if more than 10% of queries cross the boundary.
I picked two databases instead of one. Non-technical editors now ship content 3x faster.
That is the punchline for Amal Najib, the B2B platform I am building for Indonesian food companies preparing for halal certification. The external deadline is October 2026 — a regulatory date nobody moves. Between now and then I need to ship three phases of content-heavy product: landing + educational content (April), halal-readiness assessment + resource library (May), case studies + FAQ (June).
The constraint that shaped the architecture is not “how do we store data.” It is “who authors it, and how often.”
The first sketch: pure Supabase
My default stack is Next.js + Supabase + Vercel. One database, one auth layer, one mental model. For an indie builder this is close to optimal — every piece of state goes through Postgres, RLS handles access, and I never context-switch.
I sketched Amal Najib this way first. Assessment questions in a questions table, resource articles in an articles table, user responses in submissions. Done.
Then I thought about who fills those tables.
The constraint that broke the sketch
Amal Najib’s content is authored by halal counselors — domain experts on Indonesian halal compliance regulation. They are not developers. They do not know SQL. They understand assessment logic deeply and they will rewrite question flows weekly as MUI (Majelis Ulama Indonesia) clarifies guidance.
A Postgres admin panel — even a nice one like Supabase Studio or a custom CRUD — puts me in the loop for every content change. Counselor writes a new question in a doc, sends it to me, I translate it to INSERT statements, I deploy.
That is fine at 3 content changes per month. It is a disaster at 30. And Amal Najib in April through June will be closer to 30.
Options matrix
I evaluated four paths:
| Tool | Visual Editor | Already in Stack | Cost | Verdict |
|---|---|---|---|---|
| Webflow | Yes | No | $$ | Strong editor, but auth/data lives elsewhere. Split grows over time |
| Airtable | Tabular only | No | $ | Fast to set up. UX for multi-paragraph authoring is poor |
| WordPress | Yes | No | $ self-host | Does not run on Vercel. New ops overhead I do not want |
| Sanity | Yes (Studio) | Ecosystem fit | Free tier covers me | Wins |
Sanity won on three axes: the Studio gives counselors a real authoring surface (not a spreadsheet), it is Vercel-native so zero new ops, and the GraphQL/GROQ API is trivial to consume from Next.js App Router.
The trade-off I was buying
Two databases means two things to reason about. The classic failure mode for a hybrid setup is bleed — editorial fields creeping into Supabase, or user data ending up denormalized in Sanity for “convenience.” Once the boundary is fuzzy, both systems become unreliable.
I wrote the boundary rule before I wrote any code:
- Sanity owns editorial content. Assessment question flows (text, options, branching logic), resource articles, FAQ items, hero copy, case study narratives. Anything a counselor authors.
- Supabase owns transactional data. User submissions, assessment results, company profiles, progress tracking. Anything the app writes automatically on behalf of a user.
The two systems never JOIN. They link by document ID.
Data flow
Counselor --> Sanity Studio --> Sanity CDN --> Next.js (ISR)
|
User --> Next.js App Router --> Supabase (RLS) --> User dashboard
|
User submission --> Supabase
Editorial content flows left-to-right, cached aggressively via ISR. Transactional data flows through Supabase with Row Level Security.
The link point is the document ID. Sanity publishes a question flow with _id: "assessment-packaging-v2". When a user submits answers, Supabase stores:
create table submissions (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users,
company_id uuid references companies,
sanity_question_flow_id text not null, -- the link
answers jsonb not null,
submitted_at timestamptz default now()
);
No foreign key across systems. Just a string reference. The Next.js layer resolves it at render time by fetching the Sanity document and joining in-memory against the Supabase row.
Why this is not slow
The concern with any cross-store join is latency. In practice:
- Sanity content is cached at the CDN edge and revalidated on publish via webhook to Next.js ISR. Read latency is ~30ms.
- Supabase queries are scoped to a single user’s rows via RLS, typically <50ms.
- The Next.js server component fetches both in parallel with
Promise.all, so total time is bounded by the slower of the two, not their sum.
On a submission page for a halal-readiness assessment, time-to-interactive sits around 400ms on a 4G connection. Good enough for a dashboard.
When this pattern is wrong
This hybrid works because Amal Najib’s data is naturally partitioned. Assessment question flows do not JOIN against user progress at the database level — they are linked by document ID and resolved in application code.
If your editorial and transactional data share heavy cross-references — say, a marketplace where product descriptions (editorial) need to JOIN against inventory, pricing, and orders (transactional) in the same query — a single RDBMS wins. You will spend more time reconciling the two stores than you save in authoring UX.
Test for fit with a question: “How many of my queries would need data from both systems in the same SQL statement?” If the answer is more than 10%, collapse to one store.
For Amal Najib the answer is zero. No query asks Sanity and Supabase for data simultaneously. They meet in React, not in SQL.
The counselor feedback loop
Three weeks into P0 and the pattern has paid off. A counselor drafted 18 educational articles and revised the landing page hero twice in one week without a single commit from me. Publish-to-live is under 60 seconds via ISR webhook.
The old version of me would have built a custom admin panel in Supabase and called it “good enough.” It would have worked. It would have been slower for the one user who matters.
Lesson
Best tech is user-dependent, not benchmark-dependent. Author persona beats dev convenience when content ships weekly.
Related:
- The Indie PM Stack 2026 — why I reuse this stack across every project.
- Solo Velocity vs Stakeholder Alignment
Key Takeaways
- Architecture decisions should track the author, not the developer. “Who writes this content, and how often?” is a stronger constraint than any benchmark. If a non-technical editor is publishing weekly, your CMS choice is already 80% decided.
- Write the boundary rule before you write the code. Hybrid stacks fail when “just this one field” bleeds across the line. A one-sentence rule (“Sanity owns editorial; Supabase owns transactional; they never JOIN”) protects the architecture from drift.
- The 10% rule tells you when to collapse. If more than 10% of your queries would need data from both stores in the same SQL statement, you’re paying reconciliation tax that beats the authoring-UX benefit. Go back to one store.
Satellite: Pythagoras (architecture) · Morgans (this post) · Pipeline: DESIGN — Pythagoras → Morgans