VOOZH about

URL: https://dev.to/mahdi_benrhouma_fe1c6005/building-offline-first-apps-with-nextjs-and-supabase-1o59

⇱ Building Offline-First Apps with Next.js and Supabase - DEV Community


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

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