# How to Add Version Control to Your App — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (backend): A 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.
- **Version History Panel** (ui): A 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.
- **Diff Viewer** (ui): Side-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.
- **Restore Action** (backend): A 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.
- **Version Label Editor** (ui): An 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.
- **content_versions Table** (data): The 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.
- **Storage Quota Manager** (backend): A 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.

## 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:

```sql
-- Version number sequence per document (prevents race conditions)
CREATE SEQUENCE IF NOT EXISTS content_version_seq;

-- Content versions table
CREATE TABLE public.content_versions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id uuid REFERENCES public.documents(id) ON DELETE CASCADE NOT NULL,
  version_number int NOT NULL,
  content jsonb NOT NULL,
  content_preview text GENERATED ALWAYS AS (LEFT(content::text, 200)) STORED,
  author_id uuid REFERENCES auth.users(id),
  label text CHECK (LENGTH(label) <= 100),
  created_at timestamptz DEFAULT now() NOT NULL
);

CREATE UNIQUE INDEX content_versions_doc_version_idx
  ON public.content_versions (document_id, version_number);

CREATE INDEX content_versions_doc_created_idx
  ON public.content_versions (document_id, created_at DESC);

-- RLS: users see versions for documents they can access
ALTER TABLE public.content_versions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view their document versions"
  ON public.content_versions FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.documents d
      WHERE d.id = document_id
        AND d.user_id = auth.uid()
    )
  );

CREATE POLICY "Service role can write versions"
  ON public.content_versions FOR INSERT
  WITH CHECK (auth.role() = 'service_role');

CREATE POLICY "Users can update version labels"
  ON public.content_versions FOR UPDATE
  USING (
    EXISTS (
      SELECT 1 FROM public.documents d
      WHERE d.id = document_id
        AND d.user_id = auth.uid()
    )
  );

-- Auto-versioning trigger: fires BEFORE UPDATE on documents
CREATE OR REPLACE FUNCTION capture_document_version()
RETURNS TRIGGER AS $$
DECLARE
  next_version int;
BEGIN
  SELECT COALESCE(MAX(version_number), 0) + 1
    INTO next_version
    FROM public.content_versions
    WHERE document_id = OLD.id;

  INSERT INTO public.content_versions
    (document_id, version_number, content, author_id)
  VALUES
    (OLD.id, next_version, OLD.content, OLD.user_id);

  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER documents_version_trigger
  BEFORE UPDATE OF content ON public.documents
  FOR EACH ROW
  WHEN (OLD.content IS DISTINCT FROM NEW.content)
  EXECUTE FUNCTION capture_document_version();
```

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 paths

### Flutterflow — fit 3/10, 6–8 hours

FlutterFlow builds the version history list and restore action UI well using Backend Queries, but the diff viewer requires a Custom Widget in Dart and the database trigger must be set up manually in the Supabase SQL Editor.

1. Run the SQL schema from this page in Supabase SQL Editor to create content_versions, the RLS policies, and the BEFORE UPDATE trigger on your documents table
2. In FlutterFlow, add a Backend Query on your version history page or bottom sheet using a Supabase query to content_versions filtered by document_id, ordered by version_number descending — select only metadata columns (version_number, author_id, label, created_at, content_preview)
3. Create a Custom Widget using diff_match_patch for Dart (pub.dev) to render the character-level diff; wire the widget to receive two text strings (old content, new content) from page state variables
4. Add a 'Restore' action flow in the Action Editor: first show an Alert Dialog for confirmation, then call your Supabase Edge Function (restore-version) passing the version row id; handle the success response by refreshing the document page state
5. Add an inline TextField to the version list row for the label field; wire it to a Supabase UPDATE action on content_versions triggered on focus loss

Limitations:

- FlutterFlow's visual query builder cannot configure Supabase database triggers — the BEFORE UPDATE trigger must be created in the Supabase SQL Editor, not inside FlutterFlow
- The diff_match_patch Custom Widget requires writing Dart code directly; FlutterFlow's no-prompt widget builder cannot produce this
- pg_cron for automated version pruning is not configurable from FlutterFlow — manage it in Supabase Dashboard under Database → Extensions
- Many-to-many document sharing (versions visible to collaborators) requires a separate document_members table and more complex RLS than the default FlutterFlow query builder supports

### Lovable — fit 4/10, 4–6 hours

Lovable handles the version history panel, diff viewer integration, and restore flow well in React; the auto-versioning trigger should be set up in Supabase rather than relying on application-level writes.

1. Run the SQL schema from this page in Supabase SQL Editor before prompting Lovable — the BEFORE UPDATE trigger must exist before your document saves begin
2. Paste the prompt below in Lovable Agent Mode; it will scaffold the VersionHistoryPanel component, the DiffViewer using diff_match_patch, and the restore Edge Function
3. In the Lovable Cloud tab, verify the content_versions table appeared with the correct columns; check Logs if the trigger function is not firing
4. Test the full cycle on the published URL: edit a document, save, open version history, select two versions, confirm the diff highlights correctly, then restore an older version and verify a 'Restored from v{N}' entry appears
5. In Supabase Dashboard under Database → Extensions, enable pg_cron and add a nightly job to prune versions older than the 50-version limit per document

Starter prompt:

```
Build a document version control feature for my React app using Supabase. The documents table already exists with an id (uuid), user_id (uuid), title (text), and content (jsonb) column. A Supabase BEFORE UPDATE trigger already writes old content to the content_versions table (schema: id, document_id, version_number, content jsonb, content_preview text, author_id, label, created_at) before every save — do not add any client-side version write logic.

Build these components:
1. VersionHistoryPanel: a shadcn/ui Sheet (side drawer) showing content_versions for the current document, ordered by version_number descending. Fetch only metadata columns (version_number, author_id, label, created_at, content_preview) — never the full content jsonb for all rows. Each row shows version number, relative timestamp (e.g. '2 hours ago'), author name, content_preview, and an inline editable label field (max 100 chars) that fires a Supabase UPDATE on blur.
2. DiffViewer: a modal using diff_match_patch (npm) to show character-level diff between two selected versions. Highlight insertions in green, deletions in red strikethrough. Normalize JSON content by sorting keys before diffing to avoid false positives from field reordering.
3. RestoreAction: a button on each version row that shows a confirmation dialog ('Restore v{N} from {date}? Your current draft will be saved first.'), then calls a Supabase Edge Function restore-version passing the version id. The Edge Function should: (a) INSERT the current document content as a new version labeled 'Draft before restore', (b) UPDATE documents SET content = selected_version.content, (c) return the new version list. After success, refresh the panel.
4. Max versions: after every restore, check if version count exceeds 50 for this document; if so delete the oldest versions beyond the limit.

Edge cases: if two users save simultaneously, the trigger handles versioning atomically — do not add any race-condition workaround in client code. Show a toast 'Version history unavailable' if content_versions query fails rather than crashing the panel. Version label field should be disabled for restore-generated entries (label starts with 'Restored from' or 'Draft before').
```

Limitations:

- Lovable may generate an application-level version write in addition to the database trigger — remove any duplicate client-side version INSERT to avoid double entries
- The Lovable preview cannot simulate Supabase BEFORE UPDATE triggers; always test versioning on the published URL where real document saves occur
- Collaborative versioning (multiple users editing the same document) requires Supabase Realtime subscription to detect remote saves — Lovable won't add this without explicit prompting

### Custom — fit 5/10, 1–3 weeks

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.

1. Implement semantic versioning (major.minor.patch) if branching (draft vs published) is required — the simple auto-increment scheme is insufficient for draft/publish workflows
2. Add 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
3. Build delta compression for large documents using binary diff algorithms rather than full JSONB snapshots — critical for rich text documents with embedded images
4. Implement storage tiering: keep the last 30 versions in the primary database and archive older versions to Supabase Storage as compressed JSONB blobs
5. Build 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

Limitations:

- 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

## Gotchas

- **Version history has gaps — some saves never created a version** — 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** — 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** — 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** — 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** — 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

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

---

Source: https://www.rapidevelopers.com/app-features/version-control
© RapidDev — https://www.rapidevelopers.com/app-features/version-control
