shadcn registry
v1.0.0

offline-sync

A complete offline-first sync system for Next.js. Works with Dexie (IndexedDB), Prisma (PostgreSQL), and includes PWA support. Installable as a single shadcn component.

Next.js
Dexie
Prisma
PWA
Zod
TypeScript

Quick Install

pnpm dlx shadcn@latest add https://offline-sync.desishub.com/r/offline-sync.json

Or configure a namespace in your components.json for shorter installs.

What is offline-sync?

offline-sync is a drop-in shadcn component that adds full offline-first data management to your Next.js app. It gives you a local IndexedDB database that works without internet, and automatically syncs changes to your PostgreSQL server when connectivity is restored.

Offline-First
All writes go to IndexedDB first. Your app works instantly, even without internet. No loading spinners, no failed requests.
Auto Sync
When online, pending changes sync to the server via delta sync. Only changed records are transferred, not the entire dataset.
Conflict Detection
Version-based conflict detection with server-wins resolution. Every record has a version number that increments on each change.
Validated
All server actions validate input with Zod schemas. No unvalidated data reaches your database.
Smart Retry
Exponential backoff (1s, 2s, 4s, 8s, 16s). Warns user after 3 failures, auto-switches to offline mode after 5.
PWA Ready
Includes service worker and web manifest. Your app is installable on mobile and desktop with offline caching.

Architecture

The component follows a layered architecture. Each layer has a single responsibility and can be customized independently.

┌─────────────────────────────────────┐
UI: OnlineToggle + useLiveQuery
├─────────────────────────────────────┤
Provider: OnlineProvider (React Context)
├─────────────────────────────────────┤
Sync Engine: push/pull + retry
├─────────────────────────────────────┤
Repository: CRUD + merge logic
├──────────┬──────────────────────────┤
Dexie/IDB Prisma/PostgreSQL (Server)
└──────────┴──────────────────────────┘

UI Layer — The OnlineToggle component shows sync status, pending count, and a force-offline switch. Your pages read data reactively from Dexie using useLiveQuery.

Provider Layer — The OnlineProvider manages network detection, auto-sync triggers, and retry logic. Wrap your app in it.

Sync Engine — Orchestrates the push/pull cycle. Pushes pending local changes, then pulls server changes since last sync.

Repository — All CRUD operations go through here. Creates UUID-based records with sync metadata. Handles soft deletes and merge logic.

What Gets Installed

Running the install command adds the following files to your project:

lib/offline-sync-types.ts

TypeScript interfaces for all entities and sync types

lib/offline-sync-db.ts

Dexie database instance with categories, products, and syncMeta tables

lib/offline-sync-validation.ts

Zod schemas for input validation and sync payload validation

lib/offline-sync-repository.ts

Full CRUD + sync helpers (create, update, soft-delete, merge, mark synced)

lib/offline-sync-engine.ts

Push/pull sync orchestration with exponential backoff retry

lib/offline-sync-actions.ts

Server actions: pushChanges, pullChanges, healthCheck (requires "use server")

components/online-provider.tsx

React context provider for network state, sync control, and auto-retry

components/online-toggle.tsx

UI component: sync button, pending badge, force-offline switch

components/pwa-register.tsx

Service worker registration component

public/sw.js

Service worker with network-first + stale-while-revalidate strategies

public/manifest.json

PWA web manifest for installability

prisma/schema.offline-sync.prisma

Reference Prisma schema — merge into your own schema.prisma

Also auto-installs shadcn components: button, switch, label, badge, sonner. And npm packages: dexie, zod, date-fns, lucide-react.

Installation Guide

1Prerequisites

You need a Next.js project with shadcn/ui initialized, and Prisma set up with PostgreSQL.

terminal
# Create a new Next.js project
pnpm create next-app@latest my-app --typescript --tailwind --app
cd my-app

# Initialize shadcn
pnpm dlx shadcn@latest init

# Install Prisma
pnpm add @prisma/client @prisma/adapter-pg pg
pnpm add -D prisma
npx prisma init

2Install the component

terminal
pnpm dlx shadcn@latest add https://offline-sync.desishub.com/r/offline-sync.json

This installs all 12 files, 5 shadcn dependencies, and 4 npm packages automatically.

3Set up the Prisma schema

Merge the installed prisma/schema.offline-sync.prisma into your prisma/schema.prisma. Each sync-enabled model needs these fields:

prisma/schema.prisma
model Category {
  id        String    @id @default(uuid())
  name      String
  version   Int       @default(1)       // Conflict detection
  isDeleted Boolean   @default(false)   // Soft deletes
  updatedAt DateTime  @updatedAt        // Delta sync
  createdAt DateTime  @default(now())
  products  Product[]
}

model Product {
  id         String   @id @default(uuid())
  name       String
  price      Float
  categoryId String
  version    Int      @default(1)
  isDeleted  Boolean  @default(false)
  updatedAt  DateTime @updatedAt
  createdAt  DateTime @default(now())
  category   Category @relation(fields: [categoryId], references: [id])
}
terminal
# Push schema to database
npx prisma db push

# Generate Prisma client
npx prisma generate

4Set up the Prisma client

The server actions import from @/lib/prisma. Create this file:

lib/prisma.ts
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const adapter = new PrismaPg(pool);

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

const db = globalForPrisma.prisma ?? new PrismaClient({ adapter });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = db;
}

export default db;

5Wire up the providers

Wrap your app with OnlineProvider and add PWARegister in your root layout:

app/layout.tsx
import { OnlineProvider } from "@/components/online-provider";
import { PWARegister } from "@/components/pwa-register";
import { Toaster } from "@/components/ui/sonner";

export const metadata = {
  title: "My App",
  manifest: "/manifest.json",
  appleWebApp: { capable: true, statusBarStyle: "default" },
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
      </head>
      <body>
        <PWARegister />
        <OnlineProvider>{children}</OnlineProvider>
        <Toaster />
      </body>
    </html>
  );
}

6Use it in your pages

Import from the repository for writes and use useLiveQuery from dexie-react-hooks for reactive reads:

terminal
# Install dexie-react-hooks for reactive queries
pnpm add dexie-react-hooks
app/page.tsx
"use client";

import { useLiveQuery } from "dexie-react-hooks";
import { db } from "@/lib/offline-sync-db";
import * as repo from "@/lib/offline-sync-repository";
import { OnlineToggle } from "@/components/online-toggle";

export default function Home() {
  // Reactive query — UI updates instantly when Dexie data changes
  const categories = useLiveQuery(
    () => db.categories.filter((c) => !c.isDeleted).toArray(),
    []
  );

  const handleCreate = async () => {
    await repo.createCategory("New Category");
    // No setState needed — useLiveQuery auto-updates
  };

  const handleDelete = async (id: string) => {
    await repo.deleteCategory(id);
  };

  return (
    <main className="p-8">
      <OnlineToggle />

      <button onClick={handleCreate}>Add Category</button>

      {categories?.map((cat) => (
        <div key={cat.id} className="flex items-center gap-2">
          <span>{cat.name}</span>
          <span className="text-xs">
            {cat.isSynced ? "✓ synced" : "● pending"}
          </span>
          <button onClick={() => handleDelete(cat.id)}>Delete</button>
        </div>
      ))}
    </main>
  );
}

7Add PWA icons

Create 192x192 and 512x512 PNG icons in public/icons/. These are referenced by the manifest for the installed app icon.

How Sync Works

Write Path (Offline-First)

1. User creates/updates/deletes a record

2. Repository writes to Dexie with isSynced: false, pendingOperation: "create"

3. useLiveQuery fires → UI updates instantly

(No network request needed)

Sync Path (When Online)

1. PUSH: Collect all records where pendingOperation != null

2. Send batch to server action → Prisma $transaction

3. Server checks version numbers → detects conflicts

4. Mark local records as synced (version updated)

5. PULL: Fetch server records where updatedAt > lastSyncedAt

6. Merge into Dexie (new records added, conflicts resolved)

7. Update lastSyncedAt timestamp

Soft Deletes

When you delete a record that has been synced, it's marked as isDeleted: true (soft delete). On sync, the server also marks it as deleted. After successful sync, the local record is permanently removed. Records that were never synced are hard-deleted immediately.

Conflict Resolution

Every record has a version number. When pushing an update, the server compares the client's version with the server's. If they don't match, a conflict is detected and resolved using server-wins strategy. The client receives the server's version of the record.

Namespace Configuration

For easier installs, configure a namespace in your project's components.json:

components.json
{
  "registries": {
    "@offline": "https://offline-sync.desishub.com/r/{name}.json"
  }
}

Then install with:

pnpm dlx shadcn@latest add @offline/offline-sync

API Reference

useOnline() Hook

Access sync state from any component inside OnlineProvider:

const {
  isOnline,       // boolean — effective online status
  isSyncing,      // boolean — sync in progress
  pendingCount,   // number  — unsynced records count
  lastSyncedAt,   // string | null — ISO timestamp
  forceOffline,   // boolean — manual offline override
  setForceOffline, // (value: boolean) => void
  syncNow,        // () => Promise<void> — trigger manual sync
} = useOnline();

Repository Functions

import * as repo from "@/lib/offline-sync-repository";

// Categories
await repo.createCategory("Electronics");
await repo.updateCategory(id, "Updated Name");
await repo.deleteCategory(id);
await repo.getCategories(); // excludes soft-deleted

// Products
await repo.createProduct({ name: "Phone", price: 999, categoryId });
await repo.updateProduct(id, { price: 899 });
await repo.deleteProduct(id);
await repo.getProducts();

// Sync helpers
await repo.getPendingCount();     // number of unsynced records
await repo.getLastSyncedAt();     // last sync timestamp

Built with Next.js, Dexie, Prisma, and shadcn/ui.

Open source — customize everything after installation.