Building Offline-First Apps with Next.js and Supabase
technology

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.

2026-03-24
14 min read
Building Offline-First Apps with Next.js and Supabase

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

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

|

Have more questions? Contact us