Feature spec
AdvancedCategory
personalization-ux
Build with AI
4–8 hours with FlutterFlow or Lovable
Custom build
1–3 weeks custom dev
Running cost
$0–25/mo up to 1K users (text docs); higher for binary content
Works on
Everything it takes to ship Version Control — parts, prompts, and real costs.
Document version control needs four pieces: a database trigger that captures every save, a versions table storing full content snapshots, a diff viewer showing what changed, and a restore action. With Lovable or FlutterFlow you can ship a working history panel in 4–8 hours. Supabase free tier covers storage for small document counts; pg_cron for automated version pruning requires Supabase Pro at $25/month.
What Document Version Control Actually Is
Version control for user content is fundamentally different from Git: instead of line-level diffs on code, you're storing full snapshots of a user's document on every save and letting them browse, compare, and roll back to any previous state. Every collaborative doc tool users have touched — Notion, Google Docs, Figma — ships this feature, so modern users expect it as table stakes in any serious content app. The product decisions are how many versions to keep, who can see version history, whether you track character-level diffs or just full snapshots, and how restore interacts with concurrent edits. Getting the capture mechanism right matters most: if a version is ever missed, users can't trust the history.
What users consider table stakes in 2026
- Every explicit save automatically creates a numbered version with timestamp and author name — no manual action required
- Version history panel accessible without leaving the document, showing versions in reverse chronological order
- Diff view that highlights added text in green and removed text in red/strikethrough between any two versions
- One-tap restore to any previous version, with a confirmation dialog naming which version will be applied
- User-editable version labels (e.g. 'before redesign', 'client approved v2') on any history entry
- Storage quota indicator showing how many versions are kept per document and when older ones are pruned
Anatomy of the Feature
Six components, two of which AI tools reliably generate on the first prompt. The auto-save trigger and the diff viewer are where builds fail — the trigger must live in the database to be reliable, not in application code.
Auto-Save Version Trigger
BackendA Supabase PostgreSQL BEFORE UPDATE trigger on the documents table that INSERTs the old row's content into content_versions before applying the update. Fires in the same transaction as the save — no version can be skipped even if the app crashes mid-save.
Note: This is the most critical component. If the AI generates an application-level version write instead of a database trigger, versions will be missed whenever the network drops or the browser tab closes mid-save.
Version History Panel
UIA Flutter BottomSheet or shadcn/ui ScrollArea listing content_versions rows in descending version_number order. Each row displays version_number, created_at formatted as relative time, author display name, and the optional label. Tapping a row opens the diff view or triggers restore.
Note: Query only metadata columns for the list (version_number, author_id, label, created_at, content_preview) — never fetch full content jsonb for all versions at once, as large documents make the list unacceptably slow.
Diff Viewer
UISide-by-side or inline character-level diff powered by the diff_match_patch library — available as a Dart package (pub.dev) for Flutter and an npm package for web. Highlights insertions in green and deletions in red with strikethrough; handles plain text and JSON content fields.
Note: Always normalize JSONB before diffing by sorting keys with a stable stringify. Running diff_match_patch on raw JSON.stringify output causes spurious diffs from field reordering, making every version appear fully changed.
Restore Action
BackendA Supabase Edge Function or Next.js Server Action that copies content_versions.content back to the main documents row. Critically, it must write a new version entry labeled 'Restored from v{N}' so the restore event itself is visible in history — preventing silent overwrites of the current draft.
Note: Show a confirmation dialog before executing restore. Offer a 'Save current work as a new version first' option for users who want to preserve their current draft before rolling back.
Version Label Editor
UIAn inline text field on each version history row allowing users to add or edit a label up to 100 characters. Triggers a Supabase UPDATE on content_versions SET label = ? for that row. Labels persist across sessions and are visible to all users who can view the document.
Note: Limit label length to 100 characters at the database level with a CHECK constraint to prevent edge cases.
content_versions Table
DataThe persistence layer storing full content snapshots per version. Includes version_number (auto-incremented per document via a Postgres sequence), content as jsonb or text, author_id referencing auth.users, label text, and a content_preview text column storing the first 200 characters for fast list rendering.
Note: Use a UNIQUE index on (document_id, version_number) to prevent duplicate version numbers. For text documents, JSONB snapshots are compact — 10,000 versions of a 5KB document occupy roughly 50MB.
Storage Quota Manager
BackendA pg_cron job (available on Supabase Pro) running nightly to DELETE FROM content_versions WHERE version_number <= (SELECT MAX(version_number) - 50 FROM content_versions WHERE document_id = ...). Enforces the per-document version limit to prevent unbounded storage growth.
Note: pg_cron requires Supabase Pro ($25/mo). On the free tier, implement application-level pruning: after each INSERT into content_versions, check the count and delete the oldest row if count exceeds the limit.
The data model
Two tables: the main documents table (which you likely already have) and content_versions for the snapshots. Run this in the Supabase SQL Editor — it sets up the trigger, the sequence-based version numbering, RLS policies, and the performance index in one pass:
1-- Version number sequence per document (prevents race conditions)2CREATE SEQUENCE IF NOT EXISTS content_version_seq;34-- Content versions table5CREATE TABLE public.content_versions (6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),7 document_id uuid REFERENCES public.documents(id) ON DELETE CASCADE NOT NULL,8 version_number int NOT NULL,9 content jsonb NOT NULL,10 content_preview text GENERATED ALWAYS AS (LEFT(content::text, 200)) STORED,11 author_id uuid REFERENCES auth.users(id),12 label text CHECK (LENGTH(label) <= 100),13 created_at timestamptz DEFAULT now() NOT NULL14);1516CREATE UNIQUE INDEX content_versions_doc_version_idx17 ON public.content_versions (document_id, version_number);1819CREATE INDEX content_versions_doc_created_idx20 ON public.content_versions (document_id, created_at DESC);2122-- RLS: users see versions for documents they can access23ALTER TABLE public.content_versions ENABLE ROW LEVEL SECURITY;2425CREATE POLICY "Users can view their document versions"26 ON public.content_versions FOR SELECT27 USING (28 EXISTS (29 SELECT 1 FROM public.documents d30 WHERE d.id = document_id31 AND d.user_id = auth.uid()32 )33 );3435CREATE POLICY "Service role can write versions"36 ON public.content_versions FOR INSERT37 WITH CHECK (auth.role() = 'service_role');3839CREATE POLICY "Users can update version labels"40 ON public.content_versions FOR UPDATE41 USING (42 EXISTS (43 SELECT 1 FROM public.documents d44 WHERE d.id = document_id45 AND d.user_id = auth.uid()46 )47 );4849-- Auto-versioning trigger: fires BEFORE UPDATE on documents50CREATE OR REPLACE FUNCTION capture_document_version()51RETURNS TRIGGER AS $$52DECLARE53 next_version int;54BEGIN55 SELECT COALESCE(MAX(version_number), 0) + 156 INTO next_version57 FROM public.content_versions58 WHERE document_id = OLD.id;5960 INSERT INTO public.content_versions61 (document_id, version_number, content, author_id)62 VALUES63 (OLD.id, next_version, OLD.content, OLD.user_id);6465 RETURN NEW;66END;67$$ LANGUAGE plpgsql SECURITY DEFINER;6869CREATE TRIGGER documents_version_trigger70 BEFORE UPDATE OF content ON public.documents71 FOR EACH ROW72 WHEN (OLD.content IS DISTINCT FROM NEW.content)73 EXECUTE FUNCTION capture_document_version();Heads up: The WHEN clause on the trigger means only real content changes create versions — updating a title or last_opened_at won't flood the versions table. The SECURITY DEFINER function runs as the database owner, bypassing RLS for the trigger insert while the service_role policy covers direct Edge Function writes.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Custom development is the right path when versioning is a strategic differentiator: collaborative multi-user editing, branching workflows, binary file versioning, or compliance retention requirements.
Step by step
- 1Implement semantic versioning (major.minor.patch) if branching (draft vs published) is required — the simple auto-increment scheme is insufficient for draft/publish workflows
- 2Add operational transformation (OT) or CRDT (Yjs library) for collaborative real-time editing where multiple users can modify the same document simultaneously without creating conflicting versions
- 3Build delta compression for large documents using binary diff algorithms rather than full JSONB snapshots — critical for rich text documents with embedded images
- 4Implement storage tiering: keep the last 30 versions in the primary database and archive older versions to Supabase Storage as compressed JSONB blobs
- 5Build compliance retention: pg_cron jobs that enforce minimum retention periods (e.g. all versions for 7 years for SOC 2 or legal hold), overriding the normal pruning logic based on user plan
Where this path bites
- Collaborative editing with OT/CRDT adds 2–3 extra weeks of development for conflict resolution logic alone
- Binary file versioning (PDFs, design files, images) requires efficient delta storage rather than full snapshots — each snapshot of a 5MB design file multiplies storage costs rapidly
Third-party services you'll need
Version control is primarily a database feature. The only paid services are Supabase Pro for pg_cron automation and the diff library (which is free):
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Database triggers for auto-versioning, JSONB snapshot storage, RLS, and pg_cron for automated version pruning | 500MB DB, 2 projects — sufficient for small document counts | $25/mo (Pro) — required for pg_cron |
| diff_match_patch | Character-level diff computation for the diff viewer; available for both Dart (pub.dev) and JavaScript (npm) | Open-source (Apache 2.0), zero cost | Free |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers storage for moderate version counts. Text document JSONB snapshots are compact — 50 versions of a 5KB doc is ~250KB per document. All diff computation is client-side with no API costs. Note: pg_cron for automated pruning needs Pro; on free tier, implement application-level pruning.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Version history has gaps — some saves never created a version
Symptom: When AI tools implement versioning at the application layer, a client-side fetch of the current content followed by an INSERT into content_versions loses the version if the browser tab closes, the network drops, or the user navigates away between the fetch and the write. This is the most common failure mode and produces a version history users can't trust.
Fix: Implement versioning as a Supabase BEFORE UPDATE trigger on the documents table. The trigger fires atomically in the same transaction as the content UPDATE — no versions can be skipped. Ask explicitly for a database trigger in your prompt and verify in Supabase Dashboard that the trigger is listed under Database → Triggers.
Restore silently overwrites current unsaved work
Symptom: AI tools often generate a restore action that immediately replaces document content without prompting the user. If the user has unsaved edits in the editor at the time of restore, those edits are permanently lost with no recourse — a trust-destroying experience.
Fix: Show a confirmation bottom sheet or modal naming the specific version being restored ('Restore v5 from Jan 12, 2:30pm?'). Offer a 'Save current draft first' option that triggers a document save (creating a new version) before executing the restore. Make the restore button itself a two-step action.
Diff view shows the entire document as changed instead of just modified lines
Symptom: When diff_match_patch runs on JSON.stringify of a JSONB object, field ordering in the stringified output can differ between versions even when the logical content is identical. The diff engine then marks huge swaths of unchanged content as modified, making the diff viewer useless.
Fix: Before passing content to diff_match_patch, normalize the JSONB by sorting all keys alphabetically with a stable stringify function (json-stable-stringify for npm, a sort-keys function in Dart). Alternatively, diff only the text content field extracted from the JSONB rather than the full JSON blob.
Duplicate version numbers after concurrent saves
Symptom: When version_number is generated as MAX(version_number) + 1 at the application layer, two concurrent saves both read the same MAX value and assign the same version number. This violates the UNIQUE index and causes one of the writes to fail with a constraint error, resulting in a lost save.
Fix: Use a Postgres SEQUENCE or compute version_number inside the database trigger function using a SELECT MAX + 1 within the trigger transaction — the trigger runs inside the UPDATE transaction's lock, preventing the race. Alternatively, use a generated serial column. Never compute the next version number in client-side application code.
Version history list loads slowly for documents with many versions
Symptom: AI tools often generate a query that fetches the full content jsonb for every version to render the history list. For a document with 100 versions at 10KB each, this is a 1MB payload rendered in a scrollable list — causing a 3–5 second load time and visible layout shift.
Fix: Add a content_preview text column to content_versions storing only the first 200 characters. Query only metadata columns (version_number, author_id, label, created_at, content_preview) for the history list. Load full content only when the user explicitly opens the diff view or triggers a restore — two small targeted fetches replace one large all-data fetch.
Best practices
Always implement auto-versioning as a database trigger, not application code — triggers are atomic and can't be skipped by network failures or tab closes
Store a content_preview column (first 200 chars) on version rows so the history list never fetches full content blobs
Show a confirmation dialog before every restore that names the specific version and offers to save the current draft first
Normalize JSONB before diffing by sorting keys — prevents spurious 'everything changed' diffs caused by field reordering
Enforce a max versions limit per document (50 is a sensible default) to prevent unbounded storage growth; implement nightly pruning with pg_cron on Supabase Pro
Write a new 'Restored from v{N}' version entry on every restore action so the history always reflects what actually happened to the document
Label the BEFORE UPDATE trigger with a WHEN clause (WHEN OLD.content IS DISTINCT FROM NEW.content) so only real content changes create versions — metadata updates won't pollute the history
For compliance use cases, implement a do-not-delete flag on specific versions so users can mark versions as legally significant before pruning runs
When You Need Custom Development
AI tools handle single-author document versioning reliably. Several patterns require custom development:
- Collaborative editing where multiple users can simultaneously modify the same document — this requires operational transformation (OT) or CRDT (Yjs) for conflict resolution, which is beyond what any AI builder generates reliably
- Branching model needed — a draft branch separate from a published branch with a merge workflow (approve-to-publish), common in CMS and legal document workflows
- Binary file versioning for design files, PDFs, or images — full JSONB snapshots multiply storage costs rapidly; delta compression and Supabase Storage tiering require custom architecture
- Compliance retention requirements (SOC 2, HIPAA, legal hold) mandating that all versions be retained for a minimum period with tamper-evident audit logs, overriding normal pruning
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How many previous versions are kept?
A sensible default is 50 versions per document, with nightly pruning deleting the oldest beyond that limit. You can configure this per plan tier — free users keep 10 versions, paid users keep unlimited. The limit exists to prevent unbounded storage growth; 50 versions of a typical 5KB text document adds up to only about 250KB per document.
Can I compare two older versions against each other, not just current vs previous?
Yes — the diff viewer accepts any two version numbers as input. The version history panel should let users select two rows to compare, not just compare the latest against the previous. Specify this in your Lovable or V0 prompt explicitly: 'Allow the user to select any two versions in the history panel and compare them in the diff viewer.'
What happens if two people save the same document at the same time?
With the database trigger approach, both saves create valid version entries atomically — no versions are lost. However, the second save overwrites the first person's content unless you add optimistic concurrency control (a version_number check before UPDATE). Last-write-wins is the default; add a warning toast if the version number has changed since the user last loaded the document.
Does versioning work for images and files, or just text?
The JSONB snapshot approach works for any structured data including rich text, and for documents that reference image URLs stored in Supabase Storage. Storing actual binary file contents (PDFs, raw images) as JSONB snapshots is impractical — each version would duplicate the full binary. For binary files, store a reference to a versioned Supabase Storage object instead of the content itself.
Can I label a version so I remember what changed?
Yes — the content_versions table includes a label text column that users can edit inline in the version history panel. Common labels include 'before redesign', 'client approved', 'v2 draft'. Automatically generated entries (restores) get programmatic labels like 'Restored from v5' so the history remains self-documenting.
How much storage does version history use?
For plain text documents, very little — a 5KB document with 50 versions uses roughly 250KB. For rich text with embedded image URLs, multiply by the number of large embedded data URIs. The most important practice is caching image URLs (Supabase Storage signed URLs) rather than embedding base64 image data in the JSONB content — that single decision keeps snapshot sizes manageable.
Can I restore to a version and keep editing from that point?
Yes — a restore copies the selected version's content back to the main document and creates a new version entry labeled 'Restored from v{N}'. From that point, the document is live and editable. Every subsequent save creates new versions starting from the restored state. The full history including pre-restore versions remains browsable.
Is version history visible to all users or just the author?
By default the RLS policy on content_versions restricts visibility to the document owner. For collaborative documents, extend the policy to include any user in a document_members table with an appropriate role. Version history is typically visible to all editors but restore permissions can be restricted to admins or document owners only.
Need this feature production-ready?
RapidDev builds version control into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.