Offline-First Next.js + Supabase: Sync That Actually Works
Build a Next.js app that works offline, queues writes, and syncs cleanly to Supabase when the network returns — IndexedDB, sync queue, conflict resolution, with code.
Photo by Alex Cheung on Unsplash
Most web applications assume a constant internet connection. But in reality, users experience network interruptions, slow connections, and offline periods. Offline-first architecture flips this assumption: the app works offline, and syncs when online.
This guide teaches you how to build offline-first applications with Next.js and Supabase.
Why Offline-First?#
Better User Experience:
- App responds instantly (no loading spinners)
- Works on unreliable networks
- Users can continue working offline
Business Benefits:
- Reduced server load (less frequent requests)
- Better retention (app works anywhere)
- Competitive advantage
Technical Benefits:
- Simpler error handling (no network errors)
- Better performance (local data access)
- Easier testing (no network mocking)
Architecture Overview#
┌─────────────────────────────────────────┐
│ Next.js Application │
├─────────────────────────────────────────┤
│ Local Storage Layer (IndexedDB) │
│ ├─ User data │
│ ├─ Posts │
│ └─ Sync metadata │
├─────────────────────────────────────────┤
│ Sync Engine │
│ ├─ Detect online/offline │
│ ├─ Queue changes │
│ └─ Merge conflicts │
├─────────────────────────────────────────┤
│ Supabase (Server) │
│ ├─ Source of truth │
│ ├─ Realtime updates │
│ └─ Conflict resolution │
└─────────────────────────────────────────┘Step 1: Local Storage Setup#
Using IndexedDB for Large Datasets#
// lib/db.ts
import Dexie, { Table } from 'dexie';
export interface Post {
id: string;
title: string;
content: string;
user_id: string;
created_at: string;
updated_at: string;
synced: boolean;
}
export class AppDB extends Dexie {
posts!: Table<Post>;
constructor() {
super('offline-app');
this.version(1).stores({
posts: '++id, user_id, synced'
});
}
}
export const db = new AppDB();Using localStorage for Simple Data#
// lib/local-storage.ts
export const localStorageManager = {
// Save data
save(key: string, data: any) {
localStorage.setItem(key, JSON.stringify(data));
},
// Load data
load(key: string) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
},
// Remove data
remove(key: string) {
localStorage.removeItem(key);
},
// Get sync metadata
getSyncMetadata() {
return this.load('sync-metadata') || {
lastSync: null,
pendingChanges: []
};
},
// Update sync metadata
updateSyncMetadata(metadata: any) {
this.save('sync-metadata', metadata);
}
};Step 2: Detect Online/Offline Status#
// lib/offline-detector.ts
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Listen to online/offline events
window.addEventListener('online', () => setIsOnline(true));
window.addEventListener('offline', () => setIsOnline(false));
// Check initial status
setIsOnline(navigator.onLine);
return () => {
window.removeEventListener('online', () => setIsOnline(true));
window.removeEventListener('offline', () => setIsOnline(false));
};
}, []);
return isOnline;
}
// Better: Detect actual connectivity
export async function checkConnectivity() {
try {
const response = await fetch('/api/health', {
method: 'HEAD',
cache: 'no-store'
});
return response.ok;
} catch {
return false;
}
}Step 3: Implement Sync Engine#
// lib/sync-engine.ts
export class SyncEngine {
private supabase: SupabaseClient;
private db: AppDB;
private isSyncing = false;
constructor(supabase: SupabaseClient, db: AppDB) {
this.supabase = supabase;
this.db = db;
}
// Queue a change for sync
async queueChange(table: string, operation: 'insert' | 'update' | 'delete', data: any) {
const metadata = localStorageManager.getSyncMetadata();
metadata.pendingChanges.push({
id: crypto.randomUUID(),
table,
operation,
data,
timestamp: Date.now(),
synced: false
});
localStorageManager.updateSyncMetadata(metadata);
}
// Sync pending changes
async sync() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
const metadata = localStorageManager.getSyncMetadata();
const pendingChanges = metadata.pendingChanges.filter((c: any) => !c.synced);
for (const change of pendingChanges) {
await this.syncChange(change);
}
// Fetch latest data from server
await this.fetchLatestData();
metadata.lastSync = Date.now();
metadata.pendingChanges = metadata.pendingChanges.filter((c: any) => c.synced);
localStorageManager.updateSyncMetadata(metadata);
} finally {
this.isSyncing = false;
}
}
// Sync a single change
private async syncChange(change: any) {
try {
switch (change.operation) {
case 'insert':
await this.supabase.from(change.table).insert(change.data);
break;
case 'update':
await this.supabase
.from(change.table)
.update(change.data)
.eq('id', change.data.id);
break;
case 'delete':
await this.supabase
.from(change.table)
.delete()
.eq('id', change.data.id);
break;
}
// Mark as synced
const metadata = localStorageManager.getSyncMetadata();
const changeIndex = metadata.pendingChanges.findIndex((c: any) => c.id === change.id);
if (changeIndex !== -1) {
metadata.pendingChanges[changeIndex].synced = true;
localStorageManager.updateSyncMetadata(metadata);
}
} catch (error) {
console.error('Sync error:', error);
// Retry later
}
}
// Fetch latest data from server
private async fetchLatestData() {
const { data: posts } = await this.supabase
.from('posts')
.select('*')
.order('updated_at', { ascending: false });
if (posts) {
await this.db.posts.bulkPut(posts.map(p => ({ ...p, synced: true })));
}
}
}Step 4: Handle Conflicts#
// lib/conflict-resolver.ts
export type ConflictResolutionStrategy = 'last-write-wins' | 'user-chooses' | 'merge';
export class ConflictResolver {
// Last-write-wins: Server version overwrites local
static lastWriteWins(local: any, server: any): any {
return server;
}
// User chooses: Present both versions to user
static async userChooses(local: any, server: any): Promise<any> {
return new Promise((resolve) => {
// Show UI for user to choose
const choice = confirm(
`Conflict detected!\n\nLocal: ${JSON.stringify(local)}\n\nServer: ${JSON.stringify(server)}\n\nUse server version?`
);
resolve(choice ? server : local);
});
}
// Merge: Combine both versions
static merge(local: any, server: any): any {
return {
...server,
...local,
merged_at: new Date().toISOString()
};
}
// Detect conflict
static hasConflict(local: any, server: any): boolean {
return local.updated_at !== server.updated_at;
}
}Step 5: Implement Offline-First Component#
// components/OfflineFirstPosts.tsx
'use client';
import { useEffect, useState } from 'react';
import { useOnlineStatus } from '@/lib/offline-detector';
import { db } from '@/lib/db';
import { SyncEngine } from '@/lib/sync-engine';
import { createClient } from '@/lib/supabase/client';
export function OfflineFirstPosts() {
const [posts, setPosts] = useState([]);
const [isOnline, setIsOnline] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const supabase = createClient();
const syncEngine = new SyncEngine(supabase, db);
// Load local posts
useEffect(() => {
async function loadPosts() {
const localPosts = await db.posts.toArray();
setPosts(localPosts);
}
loadPosts();
}, []);
// Detect online status
useEffect(() => {
window.addEventListener('online', () => {
setIsOnline(true);
handleSync();
});
window.addEventListener('offline', () => setIsOnline(false));
return () => {
window.removeEventListener('online', () => setIsOnline(true));
window.removeEventListener('offline', () => setIsOnline(false));
};
}, []);
// Sync when online
async function handleSync() {
setIsSyncing(true);
try {
await syncEngine.sync();
const updatedPosts = await db.posts.toArray();
setPosts(updatedPosts);
} finally {
setIsSyncing(false);
}
}
// Create post (works offline)
async function createPost(title: string, content: string) {
const newPost = {
id: crypto.randomUUID(),
title,
content,
user_id: 'current-user-id',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
synced: false
};
// Save locally
await db.posts.add(newPost);
setPosts([...posts, newPost]);
// Queue for sync
await syncEngine.queueChange('posts', 'insert', newPost);
// Sync if online
if (isOnline) {
await handleSync();
}
}
return (
<div>
<div className="status-bar">
{isOnline ? (
<span className="online">🟢 Online</span>
) : (
<span className="offline">🔴 Offline</span>
)}
{isSyncing && <span className="syncing">Syncing...</span>}
</div>
<div className="posts">
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
{!post.synced && <span className="badge">Pending</span>}
</article>
))}
</div>
<form onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost(
formData.get('title') as string,
formData.get('content') as string
);
}}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
</div>
);
}Step 6: Real-Time Sync with Supabase#
// lib/realtime-sync.ts
export function setupRealtimeSync(supabase: SupabaseClient, db: AppDB) {
// Subscribe to changes
supabase
.from('posts')
.on('*', async (payload) => {
if (payload.eventType === 'INSERT') {
await db.posts.add(payload.new);
} else if (payload.eventType === 'UPDATE') {
await db.posts.update(payload.new.id, payload.new);
} else if (payload.eventType === 'DELETE') {
await db.posts.delete(payload.old.id);
}
})
.subscribe();
}Testing Offline Functionality#
// Test offline mode
async function testOffline() {
// Simulate offline
window.dispatchEvent(new Event('offline'));
// Create post (should work)
await createPost('Test', 'Content');
// Verify it's queued
const metadata = localStorageManager.getSyncMetadata();
console.log('Pending changes:', metadata.pendingChanges);
// Simulate online
window.dispatchEvent(new Event('online'));
// Verify sync happens
await new Promise(resolve => setTimeout(resolve, 1000));
const posts = await db.posts.toArray();
console.log('Synced posts:', posts);
}Best Practices#
- ✅ Store data locally first, sync asynchronously
- ✅ Show offline status to users
- ✅ Queue changes for sync
- ✅ Handle conflicts gracefully
- ✅ Test on slow networks
- ✅ Implement retry logic
- ✅ Monitor sync status
- ✅ Clean up old data periodically
- ✅ Use IndexedDB for large datasets
- ✅ Implement proper error handling
Related Articles#
- Building Real-Time Collaboration Features
- Progressive Web Apps Complete Guide
- Building SaaS with Next.js and Supabase
- 7 Next.js + Supabase Architecture Decisions I'd Make Differently
- The Hidden Costs of Building a Full-Stack B2B SaaS in 2026
Conclusion#
Offline-first architecture provides better user experience, especially on unreliable networks. Start with local storage, implement a sync engine, handle conflicts, and test thoroughly. With these techniques, you'll build resilient applications that work anywhere.
The key is thinking about data flow differently: local first, sync later. This mindset shift leads to more robust, user-friendly applications.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
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.
Supabase + 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.
Browse by Topic
Find stories that matter to you.
