# How to Add Auto-Save to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Auto-save needs three layers: a dirty-state tracker that detects changes, a debounced save (1500ms after last keystroke) plus a 30-second interval fallback, and a four-state status indicator (idle, saving, saved, error). The trickiest parts are the useEffect cleanup to prevent memory leaks and a beforeunload handler for emergency localStorage backup. With V0 or Lovable you can ship this in 1–2 hours for $0–$25/month depending on Supabase tier.

## What Auto-Save Actually Is — and What Makes It Hard

Auto-save sounds like a simple setInterval that writes to the database every 30 seconds. In practice, three subtleties separate a production implementation from a broken one. First, saving on every keystroke hammers the database and creates a terrible UX — the correct pattern debounces 1500ms after the last keystroke, with the 30-second interval as a fallback for users who pause mid-thought. Second, the setInterval must be cleaned up when the component unmounts or the app leaks memory and writes to Supabase after the user has navigated away. Third, two browser tabs editing the same document will silently overwrite each other without conflict detection on updated_at. Each of these failure modes ships in AI-generated code unless explicitly specified in the prompt.

## Anatomy of the Feature

Seven components make up production auto-save. AI tools generate the interval and the status indicator reliably. The conflict detection, cleanup, and dirty-state tracking need explicit prompting to avoid the bugs that surface in production.

- **Dirty State Tracker** (ui): A React useRef boolean (isDirty) that flips to true on any form change event. Using a ref rather than state prevents the change detection itself from triggering a re-render. The useDebounce hook from usehooks-ts watches the document content value and triggers the save function 1500ms after the last change — the ref is checked at save time to avoid unnecessary writes when content has not changed.
- **Auto-Save Interval** (ui): A setInterval(flush, 30000) backup that fires every 30 seconds regardless of debounce state. This catches users who type for more than 30 seconds without pausing. The interval is set up in a useEffect and the cleanup function returns () => clearInterval(id) — this is the most commonly missed line in AI-generated code. Uses Date.now() to track lastSavedAt for the status timestamp.
- **Save Status Indicator** (ui): Four states rendered via conditional className on a single status component. Idle: gray dot. Saving: shadcn/ui Loader2 spinning icon. Saved: green CheckCircle icon with 'Saved X ago' text using date-fns formatDistanceToNow(). Error: red AlertCircle icon with the error message and a Retry button that triggers an immediate save attempt. The status persists between saves so users see 'Saved 2 minutes ago' during quiet editing periods.
- **Conflict Detection** (backend): The documents table includes an updated_at timestamptz column maintained by a Supabase trigger. Before writing, the save function checks whether the server's updated_at is newer than the local lastFetchedAt timestamp. If it is, another session (or another user in collaborative mode) has saved changes since this session loaded the document. Surface a conflict modal showing both the local version and the server version with options to keep one or merge.
- **Draft Persistence** (data): A beforeunload event listener writes the current unsaved content to localStorage synchronously as an emergency backup. On component mount, compare the localStorage draft timestamp against the server document's updated_at — load whichever is newer. The Supabase table stores the primary copy; localStorage is the disaster-recovery fallback only.
- **Keyboard Shortcut Handler** (ui): A useEffect attaches a keydown listener to window for (e.metaKey || e.ctrlKey) && e.key === 's'. The first line of the handler is e.preventDefault() to block the browser's native 'Save Page As' dialog. This triggers an immediate save, bypassing both the debounce delay and the 30-second interval.
- **Optimistic Update Layer** (backend): React Query useMutation with an onMutate callback that optimistically sets the status to 'saved' before the network request resolves. onError rolls back to 'error' status with the previous content restored. onSettled calls queryClient.invalidateQueries(['document', id]) to refresh from the server after the mutation completes, ensuring the local state is accurate.

## Data model

Auto-save requires columns on your existing content table plus optional draft tracking. Run this SQL in the Supabase SQL Editor to add the necessary columns and set up the updated_at trigger:

```sql
-- Add auto-save columns to your existing documents table
-- (Adjust 'documents' to match your actual table name)
alter table public.documents
  add column if not exists draft_content jsonb,
  add column if not exists published_content jsonb,
  add column if not exists auto_saved_at timestamptz,
  add column if not exists version int not null default 1;

-- Trigger to auto-update updated_at on every row change
create or replace function public.handle_updated_at()
returns trigger language plpgsql as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

create trigger set_documents_updated_at
  before update on public.documents
  for each row execute procedure public.handle_updated_at();

-- If you don't have a documents table yet, create one:
create table if not exists public.documents (
  id uuid primary key default gen_random_uuid(),
  author_id uuid references auth.users(id) on delete cascade not null,
  title text not null default 'Untitled',
  draft_content jsonb,
  published_content jsonb,
  auto_saved_at timestamptz,
  version int not null default 1,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.documents enable row level security;

create policy "Authors can view own documents"
  on public.documents for select
  to authenticated
  using (auth.uid() = author_id);

create policy "Authors can insert own documents"
  on public.documents for insert
  to authenticated
  with check (auth.uid() = author_id);

create policy "Authors can update own documents"
  on public.documents for update
  to authenticated
  using (auth.uid() = author_id)
  with check (auth.uid() = author_id);

create index documents_author_updated_idx
  on public.documents (author_id, updated_at desc);
```

The draft_content and published_content separation lets you build a publish workflow where auto-save writes only to draft_content while published_content stays stable for readers. If you only need auto-save without a publish workflow, a single content jsonb column is sufficient.

## Build paths

### Lovable — fit 4/10, 1–2 hours

Lovable handles Supabase UPSERT patterns well and generates the debounced save with status indicator as a single well-scoped prompt. Best when auto-save is being added to a form or editor Lovable has already built.

1. Open your existing Lovable project and identify the form or editor component where auto-save will be added
2. Paste the prompt below in Agent Mode — specify useEffect cleanup and dirty-state tracking explicitly, as these are the two things Lovable most commonly omits
3. After generation, open the browser console and navigate away from the page — if you see a 'Warning: Can't perform a React state update on an unmounted component' message, the interval cleanup is missing
4. Test the conflict detection by opening the same document in two browser tabs, editing in both, and saving from the second tab — verify the conflict modal appears
5. Test the emergency backup by typing content, disconnecting your network, and reloading — the localStorage draft should restore automatically

Starter prompt:

```
Add auto-save to the existing document editor. Requirements: 1) Dirty state tracker — use a useRef boolean isDirty that sets to true on any onChange; reset to false after a successful save. 2) Debounced save — use useDebounce from usehooks-ts (1500ms delay on content change) to trigger a Supabase UPSERT of draft_content. 3) Interval backup — setInterval(flush, 30000) as a 30-second fallback; CRITICAL: the useEffect must return () => clearInterval(id) to clean up on unmount. 4) Four status states: idle (no changes), saving (Loader spinner), saved (green check + 'Saved X ago' using date-fns formatDistanceToNow), error (red warning + Retry button). 5) Cmd/Ctrl+S keyboard shortcut — attach to window keydown, call e.preventDefault() first, trigger immediate save bypassing debounce. 6) beforeunload listener — write current content to localStorage with key 'draft_' + documentId on page unload; on mount, compare localStorage draft timestamp vs server updated_at and restore whichever is newer. 7) Conflict detection — on UPSERT, check if server updated_at > local lastFetchedAt; if conflict detected show a modal with both versions. 8) Clear localStorage draft on successful save response. Use React Query useMutation with optimistic update showing 'saved' status immediately, rolling back to 'error' if the request fails.
```

Limitations:

- Lovable may generate useEffect with the document object in the dependency array, causing the save to fire on every render — specify that isDirty should be tracked via useRef and only save when isDirty is true
- The setInterval cleanup is the most commonly missed piece — explicitly state 'return () => clearInterval(intervalId) from the useEffect' and verify in the console after generation

### V0 — fit 5/10, 1–2 hours

V0 generates polished save status UI with shadcn/ui components natively. The Cmd+S keyboard handler and debounced save with usehooks-ts are patterns V0 handles reliably. Best choice for a Next.js app with an existing Supabase project.

1. Paste the prompt below in V0 — it generates a complete auto-save hook and status indicator component
2. In the Vars panel, add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in your Supabase SQL Editor
4. Test the keyboard shortcut: Cmd+S should trigger an immediate save and the browser should not open a 'Save As' dialog
5. Test the conflict: open the document in two tabs, edit both, save from tab 2, then save from tab 1 — a conflict warning should appear

Starter prompt:

```
Create a Next.js auto-save feature as a reusable hook and status component. Mark the parent component 'use client'. Hook: useAutoSave({ documentId, content, supabaseClient }) — returns { status, lastSavedAt, save }. Inside the hook: 1) useRef isDirty — set true on content change, false after save. 2) useDebounce(content, 1500) from usehooks-ts — when debounced value changes and isDirty is true, call save(). 3) useEffect with setInterval(save, 30000) — MUST return () => clearInterval(id). 4) useEffect with window keydown listener for (e.metaKey||e.ctrlKey) && e.key==='s' — e.preventDefault() first, then save(). 5) useEffect with window beforeunload listener — write content to localStorage as JSON with { content, savedAt: Date.now() } under key 'draft_' + documentId; read on mount, compare savedAt vs server updated_at, restore if newer; clear localStorage on successful save. 6) save() function: set status to 'saving'; UPSERT to Supabase documents table (draft_content, auto_saved_at = now()); on success set status to 'saved', update lastSavedAt; on error set status to 'error'. Conflict check: before upsert, fetch current updated_at from server; if server updated_at > lastFetchedAt, set status to 'conflict' and return without saving. Status component: shadcn/ui Badge — gray dot for idle, Loader2 for saving, CheckCircle + date-fns formatDistanceToNow(lastSavedAt) for saved, AlertCircle + Retry button for error, AlertTriangle for conflict. Wrap all localStorage reads in useEffect (SSR safety).
```

Limitations:

- V0 localStorage reads outside useEffect cause Next.js hydration warnings — ensure all localStorage access is inside useEffect or a 'use client' component that V0 has correctly marked
- V0 does not provision the Supabase documents table — run the SQL from this page in your Supabase SQL Editor before testing

### Custom — fit 2/10, 2–4 days

Custom development is justified only when multiple users edit the same document simultaneously. For single-user auto-save, the AI-tool paths produce identical results at a fraction of the effort.

1. Implement Yjs CRDT (Conflict-free Replicated Data Type) for real-time collaborative editing — multiple cursors, merge-free conflict resolution, and operational transformation for concurrent edits
2. Integrate Liveblocks or PartyKit as the WebSocket server that synchronizes Yjs document state across connected clients in real time
3. Build versioned document snapshots with named checkpoints that users can browse, compare (diff), and restore as distinct named saves
4. Add offline-first architecture using IndexedDB via Dexie.js so auto-save works without internet and syncs when the connection returns

Limitations:

- For single-user auto-save, custom dev is overkill by an order of magnitude — the AI paths produce a production-quality result in 1–2 hours

## Gotchas

- **Memory leak after navigating between pages** — When a user navigates away from the editor, React unmounts the component. If the setInterval is not cleared in the useEffect cleanup function, the interval continues running in the background, calling Supabase with stale content and attempting to update React state on an unmounted component. In development with React StrictMode, this appears as a console warning. In production, it silently overwrites documents with stale data. Fix: Return a cleanup function from every useEffect that sets up an interval: return () => clearInterval(intervalId). React calls this function when the component unmounts, stopping the interval. Also clear the beforeunload event listener in the same cleanup function.
- **Two browser tabs silently overwrite each other** — Both tabs save independently to the same document row. The second tab to save wins, destroying the first tab's changes. This is 'last writer wins' — a silent data loss bug that users discover only after losing work. It is especially damaging in documents where users have different sessions open on desktop and mobile. Fix: Before each UPSERT, fetch the current updated_at from the server and compare it against the timestamp of the last known server state (lastFetchedAt). If the server's timestamp is newer, another session has written to this document — surface a conflict modal showing both versions. Let the user choose which to keep before proceeding.
- **Stale draft restored from localStorage after a successful save** — The beforeunload handler writes to localStorage on every page unload. If the user closes the tab after a successful save, the last auto-save content is in both Supabase (the authoritative record) and localStorage (the emergency backup). On the next visit, the app reads localStorage, finds a timestamp, and restores it — even though the server already has this content. This causes a confusing stale-data restoration even when nothing failed. Fix: Call localStorage.removeItem('draft_' + documentId) immediately after every successful Supabase save response. On mount, only restore from localStorage if its timestamp is newer than the server's updated_at — not just if a localStorage entry exists.
- **Cmd+S opens the browser 'Save Page As' dialog instead of triggering app save** — The browser's default keyboard shortcut for Cmd/Ctrl+S is 'Save Page As'. If the keydown listener calls e.preventDefault() after some asynchronous code, or if the listener is attached to a child element rather than window, the browser default fires before the app handler runs. Fix: Make e.preventDefault() the absolute first line of the keyboard handler function — before any conditional checks or async operations. Attach the listener to window, not document.body or a specific element. Verify with DevTools: the 'Save Page As' dialog should never appear when the editor is focused.
- **Auto-save fires on every render and hammers Supabase** — If the document content is an object and that object reference is placed in a useEffect dependency array, React will re-run the effect on every render because objects are compared by reference. A component that re-renders 60 times per second will fire 60 Supabase UPSERT calls per second per user, hitting rate limits almost immediately and generating a large database bill. Fix: Track dirty state with a separate isDirty useRef boolean. Only call save() when isDirty is true. Reset isDirty to false immediately after save() is called. Do not put the entire document object in the useEffect dependency array — watch a stable primitive value like a content hash or character count instead.

## Best practices

- Debounce 1500ms after the last keystroke AND add a 30-second interval fallback — the debounce alone misses users who type for long unbroken stretches
- Return () => clearInterval(id) from every useEffect that creates an interval — this is the single most commonly missing line in AI-generated auto-save code
- Store and compare updated_at timestamps to detect conflicts before every write — silent last-writer-wins data loss is worse than surfacing a conflict modal
- Clear the localStorage draft key on every successful save response — stale drafts that restore after a successful save are as confusing as losing work
- Show a relative timestamp ('Saved 2 minutes ago') not an absolute clock time — relative timestamps communicate recency and freshness more intuitively for save indicators
- Increase the debounce delay at scale — 1500ms is right for a single user, but at 10K active users consider 3000ms to halve write volume without a perceptible UX difference
- Test the 'no internet' case by disabling your network mid-edit — the error state with a Retry button must appear, and the localStorage draft must be intact on reload

## Frequently asked questions

### How often should auto-save fire to avoid annoying users or overloading the database?

The right pattern is debounce plus interval, not one or the other. Debounce 1500ms after the last keystroke catches rapid typing sessions quickly. The 30-second interval catches long pauses. For database-conscious apps with 10K+ users, increase debounce to 3000ms — users cannot perceive the difference between 1.5 and 3 seconds, but it halves your write volume.

### What happens to unsaved work if the user's internet drops?

The beforeunload handler writes to localStorage synchronously — this works even without internet because it is a local browser operation. On reconnect, the next auto-save tick or the next Retry button click will attempt the Supabase UPSERT. For a more robust offline experience, check navigator.onLine before saving and queue the save for the 'online' event if the connection is down.

### Can auto-save work on mobile browsers?

Yes, with one caveat: the beforeunload event is unreliable on mobile Safari and Chrome for Android because mobile browsers can terminate tabs without firing beforeunload. The more reliable pattern for mobile is the visibilitychange event — when the page becomes hidden (tab switch or home button), treat it as an unload and write to localStorage immediately.

### How do I show users which version is the latest versus a draft?

Use separate database columns for draft_content and published_content. Auto-save writes to draft_content only. Publishing copies draft_content to published_content and increments a version counter. The status indicator shows 'Draft saved X minutes ago' versus 'Published version X'. Users can see they have unpublished changes when draft_content differs from published_content.

### Can I add version history so users can go back to earlier saves?

Yes — create a document_versions table (id, document_id, content jsonb, saved_at timestamptz, version int) and insert a row on each auto-save or on publish. Cap the history at the last 50 versions to avoid unbounded growth. A version history UI is a separate feature that reads from this table, but the auto-save foundation already creates the data.

### Does auto-save work in rich text editors like TipTap or Slate?

Yes. TipTap and Slate both expose onChange callbacks that fire when the editor content changes. Connect the dirty state tracker to the editor's onChange. To serialize the rich text for storage, use TipTap's JSON output (editor.getJSON()) or Slate's standard serialize function. Store the result in the draft_content jsonb column. On load, pass the stored JSON back to the editor's initialContent or value prop.

### How do I prevent auto-save from saving incomplete or invalid form data?

Run zod validation before each save and only write to Supabase if the validation passes. On failure, set status to 'idle' (do not show an error for incomplete mid-edit state) and schedule a retry after the next debounce. Alternatively, save raw draft content always but run validation only on explicit publish — this is the Google Docs pattern where drafts can be 'broken' but published versions are always valid.

### What is the difference between auto-save and autofill?

Auto-save writes form or editor content to a database as the user types, so they can recover their work later. Autofill is the browser's built-in feature that pre-populates form fields with previously entered values from browser memory. They are completely separate mechanisms. Auto-save is something you build; autofill is handled by the browser and can be disabled with autocomplete='off' on specific fields if it conflicts with your form.

---

Source: https://www.rapidevelopers.com/app-features/auto-save
© RapidDev — https://www.rapidevelopers.com/app-features/auto-save
