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.
pnpm dlx shadcn@latest add https://offline-sync.desishub.com/r/offline-sync.jsonOr configure a namespace in your components.json for shorter installs.
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.
The component follows a layered architecture. Each layer has a single responsibility and can be customized independently.
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.
Running the install command adds the following files to your project:
lib/offline-sync-types.tsTypeScript interfaces for all entities and sync types
lib/offline-sync-db.tsDexie database instance with categories, products, and syncMeta tables
lib/offline-sync-validation.tsZod schemas for input validation and sync payload validation
lib/offline-sync-repository.tsFull CRUD + sync helpers (create, update, soft-delete, merge, mark synced)
lib/offline-sync-engine.tsPush/pull sync orchestration with exponential backoff retry
lib/offline-sync-actions.tsServer actions: pushChanges, pullChanges, healthCheck (requires "use server")
components/online-provider.tsxReact context provider for network state, sync control, and auto-retry
components/online-toggle.tsxUI component: sync button, pending badge, force-offline switch
components/pwa-register.tsxService worker registration component
public/sw.jsService worker with network-first + stale-while-revalidate strategies
public/manifest.jsonPWA web manifest for installability
prisma/schema.offline-sync.prismaReference 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.
You need a Next.js project with shadcn/ui initialized, and Prisma set up with PostgreSQL.
# 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 initpnpm dlx shadcn@latest add https://offline-sync.desishub.com/r/offline-sync.jsonThis installs all 12 files, 5 shadcn dependencies, and 4 npm packages automatically.
Merge the installed prisma/schema.offline-sync.prisma into your prisma/schema.prisma. Each sync-enabled model needs these fields:
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])
}# Push schema to database
npx prisma db push
# Generate Prisma client
npx prisma generateThe server actions import from @/lib/prisma. Create this file:
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;Wrap your app with OnlineProvider and add PWARegister in your root layout:
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>
);
}Import from the repository for writes and use useLiveQuery from dexie-react-hooks for reactive reads:
# Install dexie-react-hooks for reactive queries
pnpm add dexie-react-hooks"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>
);
}Create 192x192 and 512x512 PNG icons in public/icons/. These are referenced by the manifest for the installed app icon.
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)
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
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.
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.
For easier installs, configure a namespace in your project's components.json:
{
"registries": {
"@offline": "https://offline-sync.desishub.com/r/{name}.json"
}
}Then install with:
pnpm dlx shadcn@latest add @offline/offline-syncAccess 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();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 timestampBuilt with Next.js, Dexie, Prisma, and shadcn/ui.
Open source — customize everything after installation.