writing/tutorial/2026/05
TutorialMay 13, 2026·28 min read

Real-Time PostgreSQL Sync with ElectricSQL and Next.js 15

Learn how to build offline-first, real-time applications using ElectricSQL and Next.js 15. This tutorial covers setting up Electric's sync engine, using the Shape API, and building reactive UIs that stay in sync with PostgreSQL across multiple clients.

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 useOptimistic for 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 a useShape() 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-tasks

Install the Electric client libraries:

npm install @electric-sql/react @electric-sql/client

Install helper packages for server-side mutations:

npm install postgres uuid
npm install -D @types/uuid

Step 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 -d

Verify 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:3000

Run the migration:

node --env-file=.env.local db/migrate.mjs

Step 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 dev

Next.js defaults to port 3001 since Electric already occupies port 3000.

Open http://localhost:3001 in two separate browser tabs and try:

  1. Add a task in tab one — it appears in tab two within roughly 100 milliseconds
  2. Toggle completion — both tabs update simultaneously
  3. Delete a task — disappears everywhere instantly
  4. 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:

  1. PostgreSQL with logical replication enabled — Neon, Supabase, and Amazon RDS all support this; enable with ALTER SYSTEM SET wal_level = logical;
  2. Electric Server as a Docker container on Fly.io, Railway, or your own VPS
  3. 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 WHERE clause

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_id column 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.