Content Staging and Releases Without Vendor Lock-In
If you've ever shipped a marketing site redesign where 47 pages needed to go live at exactly midnight, you know that content staging isn't a nice-to-have. It's the difference between a clean launch and a frantic Slack thread at 11:58 PM. But here's the thing -- most CMS platforms that offer content staging and scheduled releases come with strings attached. Big, expensive, lock-in-shaped strings.
I've spent the last two years building content pipelines for clients at Social Animal using combinations of headless CMS platforms, open-source tools, and custom staging workflows. What I've learned is that you don't need to hand your entire content operation over to a single vendor to get professional-grade content releases. You can build something better with open infrastructure.
This article breaks down the real trade-offs between vendor-managed content staging (like Sanity's Content Releases) and building your own with tools like Supabase feature flags, then shows you how to combine the best of both.
Table of Contents
- What Content Staging Actually Means in 2025
- The Vendor Lock-In Problem with Content Releases
- Sanity Content Releases: What You Get and What It Costs
- Building Feature Flags for Content with Supabase
- Head-to-Head: Supabase Feature Flags vs Sanity Content Releases
- Architecture: Open Infrastructure Content Staging Pipeline
- Implementation Guide
- When to Use Which Approach
- FAQ
What Content Staging Actually Means in 2025
Content staging has evolved beyond "preview before publish." In modern headless architectures, content staging means orchestrating changes across multiple content sources, ensuring visual consistency in preview environments, and releasing batches of content atomically -- meaning everything goes live together or nothing does.
Here's what a typical content release involves for the sites we build at Social Animal through our headless CMS development practice:
- Multiple document changes: 10-50 content documents that need to publish simultaneously
- Cross-reference integrity: New pages that reference new categories that reference new authors
- Preview environments: Editors need to see exactly what the staged content looks like before release
- Scheduled publishing: Content goes live at a specific time, often tied to a marketing campaign
- Rollback capability: If something breaks, you need to undo the entire release, not individual pieces
The old WordPress approach was to set each post to "draft" and then bulk-publish. That works for blog posts. It falls apart spectacularly when you're coordinating a product launch across landing pages, documentation, pricing tables, and feature comparisons.
The Three Levels of Content Staging
Not every project needs the same level of sophistication:
Level 1: Draft/Publish per document. Every CMS has this. It's fine for editorial workflows where content is independent.
Level 2: Grouped releases. Multiple documents staged together and published atomically. This is what Sanity Content Releases and similar features provide.
Level 3: Environment-based staging. Full preview environments with feature flags controlling which content version is active. This is where open infrastructure really shines.
Most teams think they need Level 2 but actually need Level 3. Here's why: Level 2 handles content changes in isolation, but real launches involve code changes, design changes, AND content changes happening together. Level 3 lets you coordinate all three.
The Vendor Lock-In Problem with Content Releases
Let me be direct about something. When a CMS vendor builds content staging into their platform, they're not doing it out of the goodness of their hearts. It's a moat. Once your editorial team relies on vendor-specific release management, switching CMS platforms means rebuilding that entire workflow from scratch.
This manifests in a few ways:
Pricing leverage. Content releases are almost always a premium feature. Sanity gates them behind their Growth plan. Contentful puts them in their Premium tier. Once your team depends on them, the vendor knows you're not going anywhere when they raise prices.
Workflow coupling. Your editors learn vendor-specific UIs and mental models. Your developers write integrations against vendor-specific APIs for release management. Your CI/CD pipeline has vendor-specific webhooks. Unwinding all of that is a 3-6 month project.
Limited customization. Vendor implementations make decisions for you. What if you need releases that span two different CMS instances? What if you need to tie content releases to feature flag rollouts in LaunchDarkly? What if you need approval workflows that don't match what the vendor imagined?
I'm not saying vendor-managed releases are always wrong. For small teams with simple needs, they can be the right call. But you should go in with eyes open about what you're trading away.
Sanity Content Releases: What You Get and What It Costs
Sanity introduced Content Releases (previously called "Spaces" in early iterations) as a way to group document changes into named releases that can be published together. Let's be fair about what it does well.
What Sanity Content Releases Actually Do
- Create named "releases" that contain draft changes to multiple documents
- Preview all changes in context before publishing
- Publish all changes atomically with a single action
- Schedule releases for future publication
- View release history and (in some cases) revert
The developer experience is solid. You query for content at a specific release perspective using Sanity's perspective parameter:
// Querying content from a specific release in Sanity
import { createClient } from '@sanity/client'
const client = createClient({
projectId: 'your-project',
dataset: 'production',
apiVersion: '2025-01-01',
useCdn: false,
})
// Fetch documents as they would appear in the 'summer-launch' release
const results = await client.fetch(
`*[_type == "landingPage"]`,
{},
{ perspective: 'release.summer-launch' }
)
The Cost Reality
As of mid-2025, Sanity's pricing for Content Releases requires the Growth plan at minimum:
- Free plan: No content releases
- Growth plan: $15/user/month -- includes basic content releases
- Enterprise: Custom pricing -- includes advanced scheduling, approval workflows
For a team of 8 editors, you're looking at $1,440/year minimum just to get grouped releases. That's before you hit API overage charges for the additional preview queries your staging workflow generates.
Is that expensive? Not inherently. But it's a recurring cost that scales with team size and locks you deeper into the Sanity ecosystem with each passing month.
Building Feature Flags for Content with Supabase
Here's where things get interesting. Supabase -- the open-source Firebase alternative -- gives you the primitives to build a content staging system that rivals vendor solutions. And because it's open infrastructure (you can self-host it), there's no lock-in.
The core idea: use Supabase as a content feature flag system that sits between your CMS and your frontend. Content exists in your CMS in its final form, but Supabase controls which version of that content is visible.
Why Supabase for This
- Row Level Security (RLS): You can create policies that control which content versions are visible based on user context (preview vs. production)
- Realtime subscriptions: Editors can see staging changes reflected instantly in preview environments
- Edge Functions: Deploy custom release logic close to your users
- Self-hostable: If you ever need to move off Supabase Cloud, you can run the whole stack yourself
- PostgreSQL underneath: Your staging metadata lives in a real database, not a proprietary system
The Basic Architecture
-- Content release management in Supabase
CREATE TABLE content_releases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'staged', 'published', 'rolled_back')),
scheduled_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE release_items (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
release_id UUID REFERENCES content_releases(id) ON DELETE CASCADE,
cms_document_id TEXT NOT NULL, -- Reference to Sanity/Contentful/whatever
cms_type TEXT NOT NULL,
content_snapshot JSONB, -- Snapshot of the content at staging time
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE feature_flags (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT false,
release_id UUID REFERENCES content_releases(id),
metadata JSONB DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT now()
);
This gives you a release management system that's completely independent of your CMS vendor. Swap out Sanity for Contentful next year? Your release management doesn't change at all.
Head-to-Head: Supabase Feature Flags vs Sanity Content Releases
Let's compare these approaches honestly:
| Factor | Sanity Content Releases | Supabase Feature Flags |
|---|---|---|
| Setup time | Minutes (built-in) | Days (custom build) |
| Monthly cost (8 editors) | ~$120/mo (Growth plan) | ~$25/mo (Supabase Pro) |
| Vendor lock-in | High (Sanity-specific) | Low (PostgreSQL + open source) |
| Preview experience | Excellent (native Studio) | Good (requires custom preview) |
| Cross-CMS releases | No (Sanity only) | Yes (CMS-agnostic) |
| Code + content releases | No | Yes (tie to deployment flags) |
| Scheduling | Built-in (Growth+) | Custom (Edge Functions + cron) |
| Rollback | Partial | Full (you control the logic) |
| Editorial UX | Polished | Depends on your implementation |
| Self-hostable | No | Yes |
| Approval workflows | Enterprise only | Custom (build what you need) |
The trade-off is clear: Sanity gives you a polished, ready-made experience. Supabase gives you flexibility and independence, but you need to build the editorial interface yourself.
Architecture: Open Infrastructure Content Staging Pipeline
Here's the architecture we've settled on for clients who need serious content staging without vendor dependency. We use this pattern frequently in our Next.js development projects and Astro builds.
The Flow
- Content authoring happens in any headless CMS (Sanity, Contentful, Strapi -- doesn't matter)
- CMS webhooks fire on content changes, pushing metadata to Supabase
- Supabase stores release groups -- which content changes belong to which release
- Preview environments query Supabase to determine which content version to show
- Release triggers (manual, scheduled, or API-driven) flip feature flags in Supabase
- ISR/on-demand revalidation in Next.js or Astro rebuilds affected pages
- Rollback reverts feature flags and triggers another revalidation
The Key Insight
Your CMS doesn't need to know about releases at all. Content just exists in its published state or its draft state in the CMS. Supabase acts as the traffic controller, deciding which content version your frontend renders.
This decoupling is incredibly powerful. It means you can:
- Use Sanity's free tier and still get content releases
- Coordinate releases across multiple CMS instances
- Tie content releases to code deployments via the same feature flag system
- Switch CMS vendors without rebuilding your release pipeline
Implementation Guide
Let's build the core of this system. I'll use Next.js as the frontend framework since it's what most of our clients use, but this pattern works with any framework.
Step 1: Supabase Release Manager
// lib/releases.ts
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
)
export async function createRelease(name: string) {
const { data, error } = await supabase
.from('content_releases')
.insert({ name, status: 'draft' })
.select()
.single()
if (error) throw error
return data
}
export async function addToRelease(
releaseId: string,
cmsDocumentId: string,
cmsType: string,
contentSnapshot: Record<string, unknown>
) {
const { error } = await supabase
.from('release_items')
.insert({
release_id: releaseId,
cms_document_id: cmsDocumentId,
cms_type: cmsType,
content_snapshot: contentSnapshot,
})
if (error) throw error
}
export async function publishRelease(releaseId: string) {
// Atomically publish: update release status and enable feature flag
const { error: releaseError } = await supabase
.from('content_releases')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', releaseId)
if (releaseError) throw releaseError
const { error: flagError } = await supabase
.from('feature_flags')
.update({ enabled: true, updated_at: new Date().toISOString() })
.eq('release_id', releaseId)
if (flagError) throw flagError
// Trigger revalidation for affected pages
await triggerRevalidation(releaseId)
}
Step 2: Content Resolution Middleware
This is where the magic happens. Your data fetching layer checks Supabase feature flags to determine which content version to serve:
// lib/content-resolver.ts
import { createClient as createSanityClient } from '@sanity/client'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
const sanity = createSanityClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2025-01-01',
useCdn: true,
})
const supabase = createSupabaseClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
)
export async function resolveContent(
documentId: string,
isPreview: boolean = false,
previewReleaseId?: string
) {
// Check if there's an active release containing this document
let releaseContent = null
if (isPreview && previewReleaseId) {
// In preview mode, show staged content from a specific release
const { data } = await supabase
.from('release_items')
.select('content_snapshot')
.eq('release_id', previewReleaseId)
.eq('cms_document_id', documentId)
.single()
releaseContent = data?.content_snapshot
} else {
// In production, check if any published release overrides this doc
const { data } = await supabase
.from('release_items')
.select('content_snapshot, content_releases!inner(status)')
.eq('cms_document_id', documentId)
.eq('content_releases.status', 'published')
.order('created_at', { ascending: false })
.limit(1)
.single()
releaseContent = data?.content_snapshot
}
if (releaseContent) {
return releaseContent
}
// Fall back to standard CMS content
return sanity.fetch(`*[_id == $id][0]`, { id: documentId })
}
Step 3: Scheduled Releases with Supabase Edge Functions
// supabase/functions/publish-scheduled/index.ts
import { createClient } from '@supabase/supabase-js'
Deno.serve(async () => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Find releases scheduled for now or earlier that haven't been published
const { data: dueReleases } = await supabase
.from('content_releases')
.select('id, name')
.eq('status', 'staged')
.lte('scheduled_at', new Date().toISOString())
if (!dueReleases?.length) {
return new Response(JSON.stringify({ published: 0 }), {
headers: { 'Content-Type': 'application/json' },
})
}
for (const release of dueReleases) {
await supabase
.from('content_releases')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', release.id)
await supabase
.from('feature_flags')
.update({ enabled: true })
.eq('release_id', release.id)
console.log(`Published release: ${release.name}`)
}
return new Response(
JSON.stringify({ published: dueReleases.length }),
{ headers: { 'Content-Type': 'application/json' } }
)
})
Set this up as a Supabase cron job running every minute, and you've got scheduled releases.
Step 4: The Preview Route
In Next.js App Router:
// app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const releaseId = searchParams.get('release')
const slug = searchParams.get('slug') || '/'
if (!releaseId) {
return new Response('Missing release parameter', { status: 400 })
}
const draft = await draftMode()
draft.enable()
// Store release ID in a cookie for the content resolver
const response = redirect(slug)
response.headers.set(
'Set-Cookie',
`preview-release=${releaseId}; Path=/; HttpOnly; SameSite=Lax`
)
return response
}
Now editors can preview any release by visiting /api/preview?release=summer-launch&slug=/products. They'll see exactly what the site will look like when that release goes live.
When to Use Which Approach
I don't believe in one-size-fits-all answers. Here's my honest recommendation:
Use Sanity Content Releases when:
- Your team is small (under 5 editors)
- You're already committed to Sanity for the long haul
- Content releases are straightforward (no cross-system coordination needed)
- You don't have developer bandwidth to build custom tooling
- Budget isn't a primary concern
Build with Supabase feature flags when:
- You need to coordinate content + code releases together
- You use multiple CMS platforms or plan to switch in the future
- Your team has specific workflow requirements that vendor tools don't support
- You want to self-host your release management infrastructure
- Long-term cost and independence matter more than setup speed
Use a hybrid approach when:
- You want Sanity's editing experience but need cross-system release coordination
- You're migrating between CMS platforms and need release management that survives the transition
- You need granular feature flags that affect both content and application behavior
The hybrid approach is actually what we recommend most often in our headless CMS engagements. Use your CMS's native draft/publish for individual documents, and layer Supabase on top for coordinated releases and feature flagging.
FAQ
What exactly is content staging in a headless CMS?
Content staging is the process of preparing, previewing, and grouping content changes before they go live. In a headless architecture, this means managing draft content across multiple API-driven content sources and ensuring that preview environments accurately reflect what published content will look like. It goes beyond simple draft/publish toggles -- real staging involves grouping related changes into releases that publish atomically.
Is Supabase really free enough to replace paid CMS features?
Supabase's free tier gives you 500MB of database storage, 50,000 monthly active users, and 500,000 Edge Function invocations. For content staging metadata (which is just release groups and feature flags -- not the content itself), that's more than enough for most teams. The Pro plan at $25/month covers much larger operations. Compare that to paying per-seat for CMS premium features, and the math works out quickly for teams larger than 3-4 editors.
Can I use this approach with Contentful, Strapi, or other CMS platforms?
Absolutely. That's the whole point. Because Supabase sits as an independent layer between your CMS and your frontend, it doesn't care where your content comes from. We've implemented this pattern with Sanity, Contentful, Hygraph, and even WordPress as the content source. The content resolver middleware just needs to know how to fetch from your specific CMS.
How do I handle rollbacks with the feature flag approach?
Rollback is actually simpler with feature flags than with vendor-managed releases. You flip the feature flag back to disabled, trigger a revalidation of affected pages, and you're done. The content snapshots stored in Supabase serve as your rollback points. In contrast, vendor-managed rollbacks often require republishing previous document versions individually.
What about real-time collaboration during content staging?
This is where vendor tools genuinely shine. Sanity's real-time collaboration in the Studio is best-in-class -- multiple editors can work on staged content simultaneously and see each other's changes. If you build your own staging layer, you can use Supabase Realtime for some of this, but you won't match the polish of native CMS collaboration features without significant investment.
Does this work with static site generators and ISR?
Yes, and it works particularly well. For Next.js with ISR (Incremental Static Regeneration) or Astro's hybrid rendering, you trigger on-demand revalidation when a release publishes. The revalidation API calls your content resolver, which now returns the newly-published content, and affected pages get regenerated. We've documented our approach in detail for clients using our Next.js development services and Astro development services.
How do I build an editorial UI for managing releases?
You have a few options. The quickest is to build a simple admin panel using Supabase's auto-generated REST API and a React component library like Shadcn/UI. It takes 2-3 days to build something usable. For more polish, you can extend Sanity Studio with a custom plugin that talks to your Supabase release management API. We've also seen teams use Retool or similar internal tool builders to create release dashboards in hours.
What are the risks of building your own content staging system?
The biggest risk is maintenance burden. Vendor-managed features get bug fixes, performance improvements, and new capabilities automatically. When you build your own, you own all of that. The second risk is edge cases -- things like conflict resolution when two releases modify the same document, or handling releases that depend on specific code changes. These are solvable problems, but they require thoughtful engineering. If your team doesn't have the capacity for that, vendor solutions are the safer bet. You can always reach out to us if you want help designing a custom staging pipeline that fits your team's needs.
How does pricing compare at scale -- say 20+ editors?
At 20 editors on Sanity Growth ($15/user/month), you're paying $3,600/year just for the plan that includes content releases. With Supabase Pro at $25/month ($300/year) plus maybe $50/month in additional compute for Edge Functions and extra database usage, you're at roughly $900/year. The gap widens further at enterprise scale. But remember to factor in the development cost of building and maintaining the custom system -- typically 40-80 hours upfront and 2-4 hours per month ongoing. For a breakdown of costs for your specific situation, check our pricing page.