Skip to main content
COSMICBYTEZLABS
NewsSecurityHOWTOsToolsStudyTraining
ProjectsChecklistsAI RankingsNewsletterStatusTagsAbout
Subscribe

Press Enter to search or Esc to close

News
Security
HOWTOs
Tools
Study
Training
Projects
Checklists
AI Rankings
Newsletter
Status
Tags
About
RSS Feed
Reading List
Subscribe

Stay in the Loop

Get the latest security alerts, tutorials, and tech insights delivered to your inbox.

Subscribe NowFree forever. No spam.
COSMICBYTEZLABS

Your trusted source for IT intelligence, cybersecurity insights, and hands-on technical guides.

429+ Articles
114+ Guides

CONTENT

  • Latest News
  • Security Alerts
  • HOWTOs
  • Projects
  • Exam Prep

RESOURCES

  • Search
  • Browse Tags
  • Newsletter Archive
  • Reading List
  • RSS Feed

COMPANY

  • About Us
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CosmicBytez Labs. All rights reserved.

System Status: Operational
  1. Home
  2. HOWTOs
  3. Building Offline-First PWAs with Next.js and SQLite
Building Offline-First PWAs with Next.js and SQLite
HOWTOAdvanced

Building Offline-First PWAs with Next.js and SQLite

Learn how to build a Progressive Web App with offline-first architecture using Next.js, SQLite for local storage, and Supabase for cloud sync. Includes...

Dylan H.

Software Engineering

February 10, 2026
5 min read

Prerequisites

  • Next.js fundamentals
  • React hooks
  • Basic SQL knowledge
  • Service Worker concepts

Overview

Offline-first architecture ensures your app works without an internet connection, syncing data when connectivity returns. This guide walks through building a production-grade PWA that stores data locally in SQLite and syncs to Supabase.

Architecture

┌─────────────────────────────────────────────────────┐
│                   Client (PWA)                       │
│  ┌─────────┐    ┌──────────┐    ┌───────────────┐   │
│  │ React UI│◄──►│ SQLite   │◄──►│ Sync Engine   │   │
│  │         │    │ (local)  │    │               │   │
│  └─────────┘    └──────────┘    └───────┬───────┘   │
│                                         │           │
│  ┌──────────────────────────────────────┘           │
│  │  Service Worker (cache + offline)                │
└──┼──────────────────────────────────────────────────┘
   │
   ▼
┌──────────────┐
│  Supabase    │
│  (cloud DB)  │
└──────────────┘

Requirements

ComponentVersion
Node.js18+
Next.js15+
better-sqlite311+
SupabaseAny

Process

Step 1: Project Setup

Initialize a Next.js project with PWA support:

npx create-next-app@latest my-pwa --typescript --app
cd my-pwa
npm install better-sqlite3 @supabase/supabase-js
npm install -D @types/better-sqlite3

Step 2: Configure the Web App Manifest

Create public/manifest.json:

{
  "name": "My Offline PWA",
  "short_name": "MyPWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#06b6d4",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Step 3: SQLite Database Layer

Create a database abstraction that works in the browser using sql.js (WASM-compiled SQLite):

// lib/local-db.ts
import initSqlJs, { Database } from "sql.js";
 
let db: Database | null = null;
 
export async function getLocalDb(): Promise<Database> {
  if (db) return db;
 
  const SQL = await initSqlJs({
    locateFile: (file) => `/sql-wasm/${file}`,
  });
 
  // Try to load existing data from IndexedDB
  const saved = await loadFromIndexedDB("app-db");
  db = saved ? new SQL.Database(saved) : new SQL.Database();
 
  // Initialize schema
  db.run(`
    CREATE TABLE IF NOT EXISTS workouts (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      date TEXT NOT NULL,
      data TEXT NOT NULL,
      synced INTEGER DEFAULT 0,
      updated_at TEXT DEFAULT (datetime('now')),
      deleted INTEGER DEFAULT 0
    )
  `);
 
  return db;
}
 
export async function saveToIndexedDB(key: string, data: Uint8Array) {
  const request = indexedDB.open("pwa-storage", 1);
  request.onupgradeneeded = () => {
    request.result.createObjectStore("databases");
  };
  return new Promise<void>((resolve) => {
    request.onsuccess = () => {
      const tx = request.result.transaction("databases", "readwrite");
      tx.objectStore("databases").put(data, key);
      tx.oncomplete = () => resolve();
    };
  });
}

Step 4: Sync Engine

Build a bidirectional sync engine with conflict resolution:

// lib/sync-engine.ts
import { createClient } from "@supabase/supabase-js";
import { getLocalDb, saveToIndexedDB } from "./local-db";
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
 
export async function syncToCloud() {
  const db = await getLocalDb();
 
  // Get unsynced local records
  const unsynced = db.exec(
    "SELECT * FROM workouts WHERE synced = 0"
  );
 
  if (unsynced.length === 0) return;
 
  const rows = unsynced[0].values.map((row) => ({
    id: row[0] as string,
    name: row[1] as string,
    date: row[2] as string,
    data: JSON.parse(row[3] as string),
    updated_at: row[5] as string,
    deleted: row[6] as number,
  }));
 
  // Upsert to Supabase with conflict resolution (last-write-wins)
  const { error } = await supabase
    .from("workouts")
    .upsert(rows, { onConflict: "id" });
 
  if (!error) {
    // Mark as synced
    db.run("UPDATE workouts SET synced = 1 WHERE synced = 0");
    await persistDb();
  }
}
 
export async function pullFromCloud() {
  const db = await getLocalDb();
 
  // Get last sync timestamp
  const result = db.exec(
    "SELECT MAX(updated_at) FROM workouts WHERE synced = 1"
  );
  const lastSync = result[0]?.values[0]?.[0] as string | null;
 
  // Fetch newer records from cloud
  let query = supabase.from("workouts").select("*");
  if (lastSync) {
    query = query.gt("updated_at", lastSync);
  }
 
  const { data } = await query;
  if (!data?.length) return;
 
  // Merge into local DB
  for (const row of data) {
    db.run(
      `INSERT OR REPLACE INTO workouts
       (id, name, date, data, synced, updated_at, deleted)
       VALUES (?, ?, ?, ?, 1, ?, ?)`,
      [row.id, row.name, row.date, JSON.stringify(row.data), row.updated_at, row.deleted ? 1 : 0]
    );
  }
 
  await persistDb();
}
 
async function persistDb() {
  const db = await getLocalDb();
  const data = db.export();
  await saveToIndexedDB("app-db", data);
}

Step 5: Service Worker for Caching

Register a service worker for offline asset caching:

// public/sw.js
const CACHE_NAME = "pwa-v1";
const STATIC_ASSETS = ["/", "/manifest.json", "/icon-192.png"];
 
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  );
});
 
self.addEventListener("fetch", (event) => {
  // Network-first for API calls
  if (event.request.url.includes("/api/")) {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }
 
  // Cache-first for static assets
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        return response;
      });
    })
  );
});

Step 6: Online/Offline Detection Hook

// hooks/useOnlineStatus.ts
import { useState, useEffect } from "react";
import { syncToCloud, pullFromCloud } from "@/lib/sync-engine";
 
export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );
 
  useEffect(() => {
    const handleOnline = async () => {
      setIsOnline(true);
      // Auto-sync when coming back online
      await syncToCloud();
      await pullFromCloud();
    };
 
    const handleOffline = () => setIsOnline(false);
 
    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);
 
    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);
 
  return isOnline;
}

Conflict Resolution Strategies

StrategyHow It WorksBest For
Last-Write-WinsNewest timestamp winsSimple apps, non-critical data
Client-WinsLocal changes always take priorityOffline-heavy apps
Server-WinsCloud version always takes priorityMulti-user apps
MergeField-level comparison and mergeComplex collaborative apps

Testing Offline Behavior

  1. Open Chrome DevTools → Application → Service Workers
  2. Check "Offline" checkbox
  3. Verify the app still loads and data operations work
  4. Uncheck "Offline" and verify sync triggers automatically

Key Takeaways

  • Store data locally first, sync to cloud as a background operation
  • Use updated_at timestamps for conflict resolution
  • Service workers handle asset caching; IndexedDB/SQLite handles data
  • Always test with network throttling and airplane mode
  • Soft-delete records (set deleted = 1) instead of hard deletes for reliable sync
#Next.js#PWA#SQLite#Supabase#Offline-First

Related Articles

IPTV Stream Validation and M3U Playlist Management with

Build a robust IPTV stream management system using FFmpeg for validation, M3U playlist parsing, EPG integration, and automated health monitoring of live...

5 min read

Building Desktop Apps with Electron and Next.js

Create cross-platform desktop applications by combining Electron for native capabilities with Next.js for the UI. Covers IPC communication, system tray,...

5 min read

Building PWAs with IndexedDB for Offline Data

Implement offline data persistence in Progressive Web Apps using IndexedDB. Covers database abstraction, CRUD operations, migration strategies, and sync...

6 min read
Back to all HOWTOs