Offline-First Next.js + Supabase: Sync That Actually Works
A typed Dexie queue, stable sync lifecycle and account-scoped cache, with explicit boundaries for offline edits and mocked failure-path tests.
TL;DR#
Store a post and its pending write in one IndexedDB transaction. Query numeric queue states, send the same UUID on retries, and remove a queued write only after Supabase confirms it succeeded. Protect unsent local edits from server snapshots and late acknowledgements. Realtime can request another refresh, but it is not the durable queue.
This example supports creation and editing of posts for one authenticated user, one open tab and one writer. It supports offline edits after an authenticated online page load. It does not implement offline authentication, offline cold starts, a service worker, deletion, multi-device conflict resolution or CRDTs. Those need additional protocols; a helper that always returns the server row is not last-write-wins.
Prerequisites: identity, schema and ownership#
Start with an authenticated Next.js application configured using the Supabase server-side client guide. Your server-side client and authentication route are application prerequisites, not provided by the sync engine. Supply the user's actual ID from a validated auth result; never hard-code a user ID or infer it from a locally stored post.
For a new demo project, the posts table and policies can be defined as follows. If posts already exists, adapt this as a migration to its real schema rather than replacing that table. The composite primary key matches the upsert conflict target in the engine.
create table public.posts (
user_id uuid not null references auth.users(id) on delete cascade,
id uuid not null,
title text not null,
content text not null,
created_at timestamptz not null,
updated_at timestamptz not null,
primary key (user_id, id)
);
alter table public.posts enable row level security;
revoke all on public.posts from anon;
grant select, insert, update on public.posts to authenticated;
create policy "read own posts" on public.posts for select to authenticated
using ((select auth.uid()) = user_id);
create policy "insert own posts" on public.posts for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "update own posts" on public.posts for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);The Supabase RLS documentation explains how policies filter access by authenticated identity. Both insert and update permission are needed for an upsert, and the code requests the saved row, so select permission is needed too. The browser uses a publishable key with the user's session. A server secret or service-role key must never be bundled into this browser code.
Before relying on this schema, exercise ownership checks with two actual users and an unauthenticated client. The RLS testing procedure explains why a privileged SQL-editor query does not establish browser isolation. The local tests described below mock HTTP failures; they do not validate a hosted project's policies.
Keep these four modules together, for example in a features/offline folder, so their relative imports match. Install the browser dependencies:
npm install dexie @supabase/supabase-js @supabase/ssrThe browser client module uses the public configuration for your project and the cookie-compatible client from the official SSR guide:
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) throw new Error('Supabase browser configuration is missing');
return createBrowserClient(url, key);
}Step 1: Local storage and an atomic pending write#
IndexedDB stores the data and queue across normal reloads. This is persistence, not a backup guarantee: users can clear site data and browsers may evict storage. Requesting persistent storage and designing recovery are separate product decisions. A closed tab also does not run the retry timer in this example; reopen the authenticated page to resume its queue.
Save this as db.ts:
import Dexie, { type Table } from 'dexie';
export interface PostRow {
id: string;
user_id: string;
title: string;
content: string;
created_at: string;
updated_at: string;
}
export interface LocalPost extends PostRow {
synced: 0 | 1;
pendingVersion: string | null;
}
export interface PendingChange {
id: string;
version: string;
data: PostRow;
synced: 0;
timestamp: number;
}
export class AppDB extends Dexie {
posts!: Table<LocalPost, string>;
pendingChanges!: Table<PendingChange, string>;
constructor(userId: string) {
super(`offline-posts-v2:${userId}`);
this.version(1).stores({
posts: 'id, user_id, synced, updated_at',
pendingChanges: 'id, synced, timestamp'
});
}
}The queue uses a number for its indexed state. Dexie's indexable-type documentation lists booleans as non-indexable. A row containing synced: false can exist in the object store while a query for numeric zero returns no matching index entries. The repaired example uses zero for pending and one for acknowledged posts; queue rows are removed after acknowledgement, so they only need the pending state.
The database name includes the authenticated user ID. Account A's application view never opens account B's queue. This namespace is not encryption: scripts running on the same origin can access that origin's storage. Choose an explicit local-data retention policy on shared machines. The example retains pending data for its original owner rather than silently deleting it on logout.
This uses a new database namespace. Existing users of the earlier offline-app example need a deliberate data migration that preserves pending writes and verifies ownership. Do not erase that database simply to make the new schema start cleanly.
Step 2: Send, acknowledge and protect local edits#
Save this as sync-engine.ts:
import type { SupabaseClient } from '@supabase/supabase-js';
import { AppDB, type PostRow } from './db';
const columns = 'id,user_id,title,content,created_at,updated_at';
export class SyncEngine {
private running: Promise<void> | null = null;
private requested = false;
private stopped = false;
constructor(
private readonly supabase: SupabaseClient,
private readonly db: AppDB,
private readonly userId: string
) {}
stop() { this.stopped = true; }
private ensureActive() {
if (this.stopped) throw new Error('This account view has been closed');
}
async save(title: string, content: string, id = crypto.randomUUID()) {
this.ensureActive();
const version = crypto.randomUUID();
const now = new Date().toISOString();
await this.db.transaction('rw', this.db.posts, this.db.pendingChanges, async () => {
this.ensureActive();
const old = await this.db.posts.get(id);
const data: PostRow = {
id, user_id: this.userId, title, content,
created_at: old?.created_at ?? now, updated_at: now
};
await this.db.posts.put({ ...data, synced: 0, pendingVersion: version });
await this.db.pendingChanges.put({
id, version, data, synced: 0, timestamp: Date.now()
});
});
}
sync(): Promise<void> {
this.ensureActive();
this.requested = true;
if (!this.running) {
this.running = this.drain().finally(() => { this.running = null; });
}
return this.running;
}
private async verifyIdentity() {
const { data, error } = await this.supabase.auth.getUser();
this.ensureActive();
if (error) throw error;
if (data.user?.id !== this.userId) throw new Error('Sign in to the original account to sync');
}
private async drain() {
while (this.requested) {
this.requested = false;
await this.verifyIdentity();
const pending = await this.db.pendingChanges.where('synced').equals(0).sortBy('timestamp');
for (const change of pending) {
await this.verifyIdentity();
const { data, error } = await this.supabase.from('posts')
.upsert(change.data, { onConflict: 'user_id,id' })
.select(columns).single();
this.ensureActive();
if (error) throw error;
const saved = data as PostRow | null;
if (!saved || saved.id !== change.id || saved.user_id !== this.userId) {
throw new Error('No matching saved row returned');
}
await this.db.transaction('rw', this.db.posts, this.db.pendingChanges, async () => {
const current = await this.db.pendingChanges.get(change.id);
const local = await this.db.posts.get(change.id);
if (current?.version === change.version && local?.pendingVersion === change.version) {
await this.db.posts.put({ ...saved, synced: 1, pendingVersion: null });
await this.db.pendingChanges.delete(change.id);
}
});
}
await this.pull();
}
}
private async pull() {
this.ensureActive();
const { data, error } = await this.supabase.from('posts')
.select(columns).eq('user_id', this.userId);
this.ensureActive();
if (error) throw error;
const rows = (data ?? []) as PostRow[];
await this.db.transaction('rw', this.db.posts, this.db.pendingChanges, async () => {
for (const row of rows) {
if (row.user_id !== this.userId) throw new Error('Unexpected row owner');
const local = await this.db.posts.get(row.id);
const pending = await this.db.pendingChanges.get(row.id);
if (pending || local?.synced === 0) continue;
if (local && Date.parse(local.updated_at) > Date.parse(row.updated_at)) continue;
await this.db.posts.put({ ...row, synced: 1, pendingVersion: null });
}
});
}
}Several details are necessary for the queue to mean what the UI says it means:
- Local save and enqueue are atomic. The Dexie transaction includes both tables. If it fails, the UI cannot display a newly saved row while forgetting its pending write.
- Only database columns cross the network. The queue's data contains the server row shape. Local flags and pendingVersion are not sent as unexpected columns to Supabase.
- Every mutation result is checked. Supabase returns an error field by default; awaiting a query does not make every database error enter a catch block. The throwOnError documentation describes the alternative that throws. This example explicitly throws a returned error before touching the queue.
- Retries preserve identity. The upsert API uses the user ID and the post ID as the conflict target. If the server commits but the response is lost, retrying the same saved payload addresses the same row. It does not create another post with a fresh UUID. This is suitable for this replaceable post snapshot, not for increments, payments or other operations with additional side effects.
- An acknowledgement belongs to one local revision. If the user edits while a request is in flight, the newer revision replaces that post's queued snapshot. The old response cannot clear the newer queue entry because its version UUID no longer matches.
- Pulls protect unsent work. A server row cannot replace a pending or dirty local row. Already acknowledged rows also reject a snapshot with an older updated_at timestamp. Comparing timestamps here prevents a local snapshot downgrade; client clocks do not provide a general cross-device conflict protocol.
The instance coalesces sync requests, so an online event, a timer and a Realtime callback do not each start a concurrent drain. On the first error, the drain stops, preserving unacknowledged writes. A later retry resumes them. A failed pull is also reported; it is not labelled a successful synchronisation.
The engine verifies the active identity before sending. If that check cannot reach the auth service, or it reports another account, sync stops and the queue stays local. This does not prevent writing locally after the application's authenticated initial load.
Step 3: A stable component and reliable cleanup#
Save this as OfflineFirstPosts.tsx:
'use client';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { AppDB, type LocalPost } from './db';
import { SyncEngine } from './sync-engine';
import { createClient } from './client';
type Actions = { save: (title: string, content: string) => Promise<void>; sync: () => Promise<void> };
function message(cause: unknown): string {
if (cause && typeof cause === 'object' && 'message' in cause) return String(cause.message);
return String(cause);
}
export function OfflineFirstPosts({ userId }: { userId: string }) {
const [supabase] = useState(() => createClient());
const [posts, setPosts] = useState<LocalPost[]>([]);
const [isOnline, setIsOnline] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [accountChanged, setAccountChanged] = useState(false);
const [error, setError] = useState<string | null>(null);
const actions = useRef<Actions | null>(null);
useEffect(() => {
let active = true;
const db = new AppDB(userId);
const engine = new SyncEngine(supabase, db, userId);
setPosts([]);
setAccountChanged(false);
const describeError = (cause: unknown) => {
if (active) setError(message(cause));
};
const load = async () => {
const rows = await db.posts.toArray();
if (active) setPosts(rows);
};
const sync = async () => {
if (!active) return;
setIsSyncing(true);
try {
await engine.sync();
if (active) setError(null);
} catch (cause) {
describeError(cause);
} finally {
if (active) {
setIsSyncing(false);
await load().catch(describeError);
}
}
};
const save = async (title: string, content: string) => {
await engine.save(title, content);
await load();
if (navigator.onLine) await sync();
};
actions.current = { save, sync };
const onOnline = () => { if (active) { setIsOnline(true); void sync(); } };
const onOffline = () => { if (active) setIsOnline(false); };
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
setIsOnline(navigator.onLine);
void load().catch(describeError);
if (navigator.onLine) void sync();
const retry = window.setInterval(() => {
if (navigator.onLine) void sync();
}, 15_000);
const channel = supabase.channel(`offline-posts:${userId}`)
.on('postgres_changes', {
event: '*', schema: 'public', table: 'posts', filter: `user_id=eq.${userId}`
}, () => { void sync(); })
.subscribe();
const { data: authListener } = supabase.auth.onAuthStateChange((_event, session) => {
if (session?.user.id !== userId) {
active = false;
engine.stop();
actions.current = null;
setPosts([]);
setAccountChanged(true);
}
});
return () => {
active = false;
engine.stop();
actions.current = null;
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
window.clearInterval(retry);
authListener.subscription.unsubscribe();
void supabase.removeChannel(channel);
db.close();
};
}, [supabase, userId]);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const values = new FormData(form);
try {
if (!actions.current) throw new Error('Account is not ready');
await actions.current.save(String(values.get('title')), String(values.get('content')));
form.reset();
} catch (cause) {
setError(message(cause));
}
}
if (accountChanged) return <p>Account changed. Reopen the authenticated page.</p>;
return (
<section>
<p>{isOnline ? 'Browser reports online' : 'Browser reports offline'}{isSyncing ? ' — syncing' : ''}</p>
{error && <p role="alert">Sync/save error: {error}. Pending writes remain local.</p>}
<button onClick={() => void actions.current?.sync()}>Retry sync</button>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2><p>{post.content}</p>
{post.synced === 0 && <span>Pending</span>}
</article>
))}
<form onSubmit={onSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Save locally</button>
</form>
</section>
);
}Render it from your authenticated page with the actual user ID and an account-specific React key. For example, once the application's existing server-side Supabase client has been created:
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) redirect('/login');
return <OfflineFirstPosts key={user.id} userId={user.id} />;That is an integration fragment: import your configured server client, the component and Next.js redirect in that page. The existing getClaims/getSession/getUser comparison explains the different auth checks; an unvalidated session object is not a server-side authorisation check.
The component creates its client once and its engine once per mounted account. It does not create a new engine on each render, which would reset the in-flight guard. Its online and offline callbacks are named function references; cleanup removes the same callbacks that were registered. The retry interval and channel are also removed when the component unmounts.
Browser connectivity is only a scheduling hint. It does not establish that Supabase is reachable, and it does not gate the local save. The queue state changes only after the request returns a checked success. Manual retry remains available when the browser's online flag is misleading. The screen reports pending writes rather than treating an offline event as proof that a server request failed.
The auth listener synchronously hides the old account's data and stops that engine after an account change. Reopen or remount the authenticated page before continuing. It does not await a Supabase request inside that callback; if existing auth code does so and requests hang, follow the onAuthStateChange deadlock fix.
Realtime is a refresh signal, not another write path#
Enable Postgres Changes for the posts table in the project's Realtime configuration if you want the optional channel above. The Supabase Postgres Changes guide documents publication and access prerequisites. The timer and manual retry still work without that channel.
A callback does not directly call add on the event's row. That would fail when an INSERT event repeats a UUID already present locally, including a post the client created offline. Instead, the callback requests the engine's ordinary authenticated pull. Its guarded put handles repeated rows while preserving queued changes. Reconnection also performs a pull, because events alone are not a complete history of what happened while disconnected.
Creation is shown in the UI. The engine's save method also accepts an existing post ID for an edit. Deletion is deliberately absent: safely synchronising deletes requires tombstones and replay rules, not merely deleting the local record. Do not combine this example with remote deletions or a second writer and expect it to reconcile those changes.
Verify failure paths before using your own data#
The local data and queue behaviour was reproduced in Docker on 13 October 2026 with Node 22.23.3, Dexie 4.4.6, fake-indexeddb 6.2.5 and supabase-js 2.117.3. The four modules were checked with TypeScript 7.0.2. fake-indexeddb simulates IndexedDB in Node; it does not replace a browser test. The Supabase requests used a controlled fetch mock, not a real project, and auth identity was supplied by a test double.
Before correction, the boolean queue contained one stored row but zero indexed pending rows. A mocked 403 mutation resolved with error code 42501 without entering the old catch block, and the old code marked it synced. A bulkPut replaced a pending local title with the stale server title; adding the same Realtime ID raised ConstraintError. Removing a new arrow function left the original listener active.
The corrected tests checked that a rejected mutation retained the pending write; a simulated committed write with a lost response remained pending; retry kept one remote row; an old response could not acknowledge a new local revision; a server snapshot arriving during a local edit preserved that edit; repeated refreshes retained one local row; and an older timestamp did not replace the newer cached row. They also checked distinct database names for different users and the stopped-engine guard.
In your browser and a non-production Supabase project, test the remaining integration conditions:
- Load the authenticated page online, then disable the network in browser DevTools. Saving should immediately create a pending local row. Re-enable the network and confirm the row becomes acknowledged only after the server accepts it.
- Make the policy reject the current user's write. Confirm the error is visible and the queue survives a reload. Restore the policy and retry; the same UUID should reach the server once as a stored row.
- Delay a response, edit that post again, then release the response. The second revision must remain pending until its own successful request.
- Sign out or switch accounts. The old account's rows must disappear from this view, and its pending queue must never be sent under the new identity.
- Navigate away and back in development, where effect setup and cleanup may run more than once. Confirm there is one active set of listeners and one sync instance per mounted account.
This is an educational queue for a small collection, not a complete replication service. Add pagination before your dataset exceeds the API's response-page limit. Multi-tab coordination, server-side conflict detection, deletion, storage eviction recovery and offline app-shell delivery need their own implementation and tests. Keep the narrower guarantee visible: local writes survive normal reloads, failed requests retain their queue entry, and a checked acknowledgement cannot silently erase a newer local edit.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
How to Structure Your Next.js App Router Project for Scale
Stop shoving everything into the components folder. Learn the Feature-Sliced Design pattern adapted perfectly for the Next.js App Router.
SupabaseSupabase + Next.js Tutorial for Beginners (Free, 2026)
Build a complete full-stack application with Next.js and Supabase from scratch. Authentication, database, CRUD operations, and deployment — all in 20 minutes.
7 Next.js + Supabase Architecture Decisions I'd Skip
After shipping multiple production apps with Next.js and Supabase, here are the decisions that cost the most time to undo — and what I'd do instead from day one.


