Modern web applications demand real-time, always-in-sync experiences. Users expect data to update instantly across devices and continue working even when offline. Traditional approaches — polling, WebSockets, and manual cache management — add significant complexity to your codebase.
ElectricSQL (or simply Electric) solves this with an open-source sync engine that sits in front of your PostgreSQL database. Instead of writing complex sync logic, you define Shapes — declarative subscriptions that describe which data to stream to the client. Electric handles the rest: real-time updates, offline queuing, and conflict-free merging.
In this tutorial, you will build a collaborative task manager that:
- Syncs tasks in real-time across multiple browser tabs
- Works offline and re-syncs automatically when connectivity returns
- Uses React 19's
useOptimisticfor instant UI feedback - Runs entirely on open-source infrastructure (PostgreSQL + Electric)
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and npm installed
- Docker and Docker Compose
- Basic knowledge of Next.js App Router and React hooks
- Familiarity with PostgreSQL and SQL basics
- A code editor (VS Code recommended)
What is ElectricSQL?
Electric is a sync engine built by the creators of PGLite. It leverages PostgreSQL's logical replication to stream row-level changes to your frontend in real-time using a simple HTTP long-polling protocol.
Key concepts:
- Shapes: A Shape is a live query — it tells Electric which rows and columns to sync to the client. Think of it as a subscription to a filtered subset of your PostgreSQL table.
- Electric Server: A lightweight proxy that connects to PostgreSQL via logical replication and serves Shapes over HTTP on port 3000.
@electric-sql/react: The React client library exposing auseShape()hook that subscribes to a Shape and returns reactive, auto-updating data.
What sets Electric apart from alternatives like Supabase Realtime or Convex:
- No vendor lock-in — works with any standard PostgreSQL database
- No custom sync code — declare Shapes, Electric does the rest
- HTTP-based protocol — works through proxies, CDNs, and load balancers
- Scales with PostgreSQL — no additional stateful infrastructure
Step 1: Set Up the Next.js Project
Scaffold a new Next.js 15 project:
npx create-next-app@latest electric-tasks --typescript --tailwind --app
cd electric-tasksInstall the Electric client libraries:
npm install @electric-sql/react @electric-sql/clientInstall helper packages for server-side mutations:
npm install postgres uuid
npm install -D @types/uuidStep 2: Run PostgreSQL and Electric with Docker
Create a docker-compose.yml at the project root:
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: electric_tasks
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
command:
- postgres
- -c
- wal_level=logical
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric_tasks
AUTH_MODE: insecure
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
postgres_data:The wal_level=logical flag is required — Electric uses PostgreSQL logical replication to track row-level changes. Without it, Electric cannot start.
Start both services:
docker compose up -dVerify Electric is healthy by visiting http://localhost:3000/v1/health — you should see {"status":"ok"}.
Step 3: Create the Database Schema
Create db/schema.sql:
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Apply the schema. Create db/migrate.mjs:
import postgres from "postgres";
import { readFileSync } from "fs";
const sql = postgres(process.env.DATABASE_URL);
const schema = readFileSync("./db/schema.sql", "utf8");
await sql.unsafe(schema);
console.log("Schema applied successfully");
await sql.end();Create .env.local:
DATABASE_URL=postgresql://postgres:password@localhost:5432/electric_tasks
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:3000Run the migration:
node --env-file=.env.local db/migrate.mjsStep 4: Define TypeScript Types
Create types/task.ts:
export interface Task {
id: string;
title: string;
completed: boolean;
created_at: string;
updated_at: string;
}Step 5: Build Server Actions for Mutations
Create app/actions.ts to handle write operations server-side:
"use server";
import postgres from "postgres";
import { v4 as uuidv4 } from "uuid";
const sql = postgres(process.env.DATABASE_URL!);
export async function createTask(title: string): Promise<void> {
await sql`
INSERT INTO tasks (id, title)
VALUES (${uuidv4()}, ${title})
`;
}
export async function toggleTask(id: string, completed: boolean): Promise<void> {
await sql`
UPDATE tasks
SET completed = ${completed},
updated_at = NOW()
WHERE id = ${id}
`;
}
export async function deleteTask(id: string): Promise<void> {
await sql`DELETE FROM tasks WHERE id = ${id}`;
}Notice there are no revalidatePath calls here — Electric's sync engine broadcasts changes directly to all subscribed clients, making manual cache invalidation unnecessary.
Step 6: Create the Electric Shape Hook
Create hooks/useTasks.ts:
"use client";
import { useShape } from "@electric-sql/react";
import type { Task } from "@/types/task";
export function useTasks() {
const { data, isLoading, error } = useShape<Task>({
url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
params: {
table: "tasks",
order_by: "created_at",
},
});
return {
tasks: (data ?? []) as Task[],
isLoading,
error,
};
}The useShape hook opens a long-polling HTTP connection to the Electric server. Whenever a row in the tasks table changes, Electric pushes the delta to all connected clients and the hook triggers a re-render. No WebSocket boilerplate required.
Step 7: Build the Task Item Component
Create components/TaskItem.tsx:
"use client";
import { useOptimistic } from "react";
import { toggleTask, deleteTask } from "@/app/actions";
import type { Task } from "@/types/task";
export function TaskItem({ task }: { task: Task }) {
const [optimisticTask, applyOptimistic] = useOptimistic(task);
async function handleToggle() {
applyOptimistic({ ...task, completed: !task.completed });
await toggleTask(task.id, !task.completed);
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg border bg-white shadow-sm">
<input
type="checkbox"
checked={optimisticTask.completed}
onChange={handleToggle}
className="h-4 w-4 cursor-pointer accent-blue-500"
/>
<span
className={`flex-1 text-sm ${
optimisticTask.completed ? "line-through text-gray-400" : "text-gray-700"
}`}
>
{task.title}
</span>
<button
onClick={() => deleteTask(task.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors"
>
Remove
</button>
</div>
);
}useOptimistic from React 19 updates the checkbox instantly before the server mutation completes, eliminating perceived latency.
Step 8: Build the Task Form
Create components/TaskForm.tsx:
"use client";
import { useRef, useTransition } from "react";
import { createTask } from "@/app/actions";
export function TaskForm() {
const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(formData: FormData) {
const title = (formData.get("title") as string).trim();
if (!title) return;
startTransition(async () => {
await createTask(title);
if (inputRef.current) inputRef.current.value = "";
});
}
return (
<form action={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
name="title"
placeholder="What needs to be done?"
disabled={isPending}
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
required
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
{isPending ? "Adding..." : "Add"}
</button>
</form>
);
}Step 9: Build the Sync Status Indicator
Create components/SyncStatus.tsx to show the Electric connection state:
"use client";
import { useTasks } from "@/hooks/useTasks";
export function SyncStatus() {
const { isLoading } = useTasks();
return (
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span
className={`h-2 w-2 rounded-full ${
isLoading ? "bg-yellow-400 animate-pulse" : "bg-green-400"
}`}
/>
{isLoading ? "Connecting..." : "Live"}
</div>
);
}Step 10: Assemble the Task List
Create components/TaskList.tsx:
"use client";
import { useTasks } from "@/hooks/useTasks";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { SyncStatus } from "./SyncStatus";
export function TaskList() {
const { tasks, isLoading, error } = useTasks();
const pending = tasks.filter((t) => !t.completed);
const done = tasks.filter((t) => t.completed);
return (
<div className="max-w-lg mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Tasks</h1>
<SyncStatus />
</div>
<TaskForm />
{error && (
<p className="text-sm text-red-500 bg-red-50 px-3 py-2 rounded-lg">
Cannot connect to Electric. Ensure the server is running on port 3000.
</p>
)}
{!error && (
<>
{pending.length > 0 && (
<section className="space-y-2">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Pending — {pending.length}
</p>
{pending.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</section>
)}
{done.length > 0 && (
<section className="space-y-2">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Completed — {done.length}
</p>
{done.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</section>
)}
{tasks.length === 0 && !isLoading && (
<p className="text-sm text-gray-400 text-center py-8">
No tasks yet. Add one above!
</p>
)}
</>
)}
</div>
);
}Step 11: Wire Up the Page
Update app/page.tsx:
import { TaskList } from "@/components/TaskList";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 py-12">
<TaskList />
</main>
);
}Step 12: Test Real-Time Sync
Start the development server:
npm run devNext.js defaults to port 3001 since Electric already occupies port 3000.
Open http://localhost:3001 in two separate browser tabs and try:
- Add a task in tab one — it appears in tab two within roughly 100 milliseconds
- Toggle completion — both tabs update simultaneously
- Delete a task — disappears everywhere instantly
- Open DevTools, go to Network, filter by
shape— observe the long-polling requests that drive the sync
Step 13: Partial Sync with Shape Filters
One of Electric's most powerful features is syncing only the data a user needs. Instead of streaming the entire table, filter by user ID or status:
export function useUserTasks(userId: string) {
const { data } = useShape<Task>({
url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
params: {
table: "tasks",
where: `user_id = '${userId}'`,
},
});
return (data ?? []) as Task[];
}This is essential for multi-tenant applications — each user downloads only their own rows, reducing bandwidth and keeping data isolated without extra middleware.
Step 14: Deploy to Production
For production you need:
- PostgreSQL with logical replication enabled — Neon, Supabase, and Amazon RDS all support this; enable with
ALTER SYSTEM SET wal_level = logical; - Electric Server as a Docker container on Fly.io, Railway, or your own VPS
- Next.js deployed to Vercel or any Node.js host
Production Electric config with JWT authentication:
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: ${DATABASE_URL}
AUTH_MODE: jwt
AUTH_JWT_ALG: ES256
AUTH_JWT_KEY: ${ELECTRIC_JWT_PUBLIC_KEY}
ports:
- "3000:3000"In production, replace insecure auth with JWT. Your Next.js API route signs a short-lived JWT that authorizes access to specific Shapes, preventing clients from accessing data they do not own.
Generate a JWT in a Next.js API route:
import { SignJWT } from "jose";
import { getServerSession } from "next-auth";
export async function GET() {
const session = await getServerSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const token = await new SignJWT({ sub: session.user.id })
.setProtectedHeader({ alg: "ES256" })
.setExpirationTime("1h")
.sign(privateKey);
return Response.json({ token });
}Testing Your Implementation
Verify the following before shipping:
- Tasks appear in a second tab within 200ms of creation
- Toggling completion reflects instantly across all open tabs
- Refreshing the page loads tasks without a visible loading flash (initial sync is fast)
- Disconnecting your network and reconnecting syncs any queued mutations
- Shape filters return only matching rows — verify with a
WHEREclause
Troubleshooting
Electric container exits immediately: Check that PostgreSQL has wal_level=logical. Verify with psql -c "SHOW wal_level;" — it must return logical, not replica or minimal.
useShape returns an empty array: Confirm the Electric URL in .env.local is correct and that NEXT_PUBLIC_ELECTRIC_URL is exposed to the client. Check the browser Network tab for failed requests to /v1/shape.
CORS errors in the browser: Either configure Electric's ALLOW_ORIGIN environment variable or proxy Electric through Next.js rewrites:
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: "/electric/:path*",
destination: "http://localhost:3000/:path*",
},
];
},
};Then update NEXT_PUBLIC_ELECTRIC_URL to /electric.
Mutations write but sync does not update the UI: Make sure you are not running Electric in AUTH_MODE: insecure behind a strict reverse proxy that blocks long-polling. Test directly against the Electric port.
Next Steps
Now that you have real-time sync working, consider these enhancements:
- Add authentication with Better Auth or Clerk and issue Electric JWTs scoped per user
- Multi-tenancy — add a
workspace_idcolumn and filter Shapes per tenant - Presence indicators — combine Electric data with a small WebSocket layer for cursor positions
- Offline mutations — use Electric's upcoming write-path feature to buffer writes locally
Conclusion
ElectricSQL transforms the complexity of real-time applications into a simple, declarative model. Instead of managing WebSocket connections, handling reconnection logic, and writing merge strategies, you define Shapes and let Electric do the heavy lifting.
The architecture is clean: Next.js Server Actions handle writes against PostgreSQL, Electric's sync engine propagates every change to all subscribed clients in real-time, and useShape delivers a reactive data stream to your components. The entire stack is open-source, self-hostable, and built on the PostgreSQL you already know.
For teams migrating from polling-based solutions, the improvement in perceived performance — and the dramatic reduction in sync-related code — makes ElectricSQL one of the most impactful additions to a modern Next.js stack in 2026.