Building Offline-First Apps with Next.js and Supabase
Learn how to build offline-first applications with Next.js and Supabase. Implement local-first data sync, conflict resolution, and seamless offline/online transitions.
Building Offline-First Apps with Next.js and Supabase#
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
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
Continue Reading
Supabase Realtime Gotchas: 7 Issues and How to Fix Them
Avoid common Supabase Realtime pitfalls that cause memory leaks, missed updates, and performance issues. Learn real-world solutions from production applications.
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.
Next.js + Supabase Performance Optimization: From Slow to Lightning Fast
Transform your slow Next.js and Supabase application into a speed demon. Real-world optimization techniques that reduced load times by 70% and improved Core Web Vitals scores.
Browse by Topic
Find stories that matter to you.