#014 April 14, 2026 · 6 min read

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.

stella-protocol architecture sanity supabase nextjs amal-najib

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.”

What you'll learn
01
Why "who authors this, and how often" is the question that should drive your CMS architecture — not "which DB is faster."
02
A boundary rule that keeps two databases from corrupting each other through "convenience" bleed.
03
The 10% rule for deciding when two stores are better than one — and when to collapse.

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:

ToolVisual EditorAlready in StackCostVerdict
WebflowYesNo$$Strong editor, but auth/data lives elsewhere. Split grows over time
AirtableTabular onlyNo$Fast to set up. UX for multi-paragraph authoring is poor
WordPressYesNo$ self-hostDoes not run on Vercel. New ops overhead I do not want
SanityYes (Studio)Ecosystem fitFree tier covers meWins

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:


Key Takeaways

  1. 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.
  2. 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.
  3. 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