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.
Originally published at https://iloveblogs.blog
Top comments (0)