ElectricSQL syncs PostgreSQL data to SQLite on the client — shapes define what data syncs, and the client always reads from a local SQLite database that stays current via streaming replication. Shape subscriptions stream partial datasets: shape({ url, params: { table, where } }) fetches rows matching a filter. useShape(shape) returns the current rows and updates reactively when sync delivers changes from Postgres. Local writes flow through the server API; Electric handles the sync back to all clients, delivering a consistent eventually-consistent view. PGlite runs a full PostgreSQL instance in WebAssembly for in-browser development without a server. Electric Cloud hosts the sync layer; self-hosted Electric connects to your own PostgreSQL. Claude Code generates Electric shape definitions, React hook integrations, local write patterns, and the deployment configuration for local-first web applications.
CLAUDE.md for ElectricSQL
## ElectricSQL Stack
- Version: @electric-sql/react >= 1.0, @electric-sql/client >= 1.0
- Shape: shape({ url: ELECTRIC_URL, params: { table, where, columns } })
- React: const { data, isLoading, error } = useShape(orderShape) — reactive sync
- Write: local writes via server API → Electric syncs change to all clients
- PGlite: browser SQLite-compatible Postgres WA — for dev and offline-capable apps
- Auth: Electric headers option with JWT for row-level shape access control
- Partial sync: where: `status != 'archived'` — sync only relevant rows to client
- Deploy: ELECTRIC_URL env pointing to Electric Cloud or self-hosted instance
Shape Definitions
// lib/electric-shapes.ts — define sync shapes
import { Shape, ShapeStream } from "@electric-sql/client"
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL!
// Helper to get auth token for Electric requests
async function getElectricToken(): Promise<string> {
const response = await fetch("/api/electric-token")
const { token } = await response.json()
return token
}
// Shape: all orders for the current user
export function createOrdersShape(customerId: string) {
return new Shape({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "orders",
where: `customer_id = '${customerId}'`,
columns: ["id", "status", "total_cents", "created_at", "tracking_number"],
},
headers: {
Authorization: async () => `Bearer ${await getElectricToken()}`,
},
})
}
// Shape: all products (shared data)
export const productsShape = new Shape({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "products",
where: "active = true",
columns: ["id", "slug", "name", "price_cents", "stock"],
},
})
// Shape: single order with its items
export function createOrderDetailShape(orderId: string) {
return new Shape({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "order_items",
where: `order_id = '${orderId}'`,
},
})
}
// ShapeStream: lower-level streaming for manual control
export function watchOrderUpdates(
orderId: string,
onUpdate: (rows: any[]) => void
) {
const stream = new ShapeStream({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: "orders",
where: `id = '${orderId}'`,
},
})
const unsubscribe = stream.subscribe(messages => {
const rows = messages
.filter(m => m.headers.action !== "delete")
.map(m => m.value)
onUpdate(rows)
})
return unsubscribe
}
React Hooks
// hooks/useOrders.ts — reactive order data from ElectricSQL
import { useShape } from "@electric-sql/react"
import { useMemo } from "react"
import { createOrdersShape } from "@/lib/electric-shapes"
import { useAuth } from "@/lib/auth"
interface Order {
id: string
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"
total_cents: number
created_at: string
tracking_number: string | null
}
export function useOrders(filters?: { status?: string }) {
const { userId } = useAuth()
const shape = useMemo(() => createOrdersShape(userId!), [userId])
const {
data: rawData,
isLoading,
error,
} = useShape<Order>(shape)
const orders = useMemo(() => {
if (!rawData) return []
let result = rawData
if (filters?.status) {
result = result.filter(o => o.status === filters.status)
}
return result.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
}, [rawData, filters?.status])
return { orders, isLoading, error }
}
// Component using the hook
export function OrdersList() {
const [statusFilter, setStatusFilter] = useState<string | undefined>()
const { orders, isLoading } = useOrders({ status: statusFilter })
if (isLoading) return <OrdersListSkeleton />
return (
<div>
<div className="flex gap-2 mb-4">
{["all", "pending", "shipped", "delivered"].map(status => (
<button
key={status}
onClick={() => setStatusFilter(status === "all" ? undefined : status)}
className={cn(
"px-3 py-1 rounded-full text-sm",
(status === "all" && !statusFilter) || statusFilter === status
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{status}
</button>
))}
</div>
{orders.map(order => (
<OrderCard key={order.id} order={order} />
))}
{orders.length === 0 && (
<p className="text-center text-muted-foreground py-12">No orders found</p>
)}
</div>
)
}
Local Writes with Electric Sync
// lib/order-mutations.ts — write to server, Electric syncs to clients
export async function createOrder(input: CreateOrderInput): Promise<Order> {
// Write goes to your API server → database → Electric replicates to all clients
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message ?? "Failed to create order")
}
const order = await response.json()
// No need to update local state — Electric sync delivers the change
// automatically to all components using useShape for the orders table
return order
}
export async function cancelOrder(orderId: string): Promise<void> {
const response = await fetch(`/api/orders/${orderId}/cancel`, {
method: "POST",
})
if (!response.ok) throw new Error("Failed to cancel order")
// Electric delivers the status change to all subscribed clients
}
PGlite In-Browser
// lib/pglite-dev.ts — in-browser PostgreSQL for development/offline
import { PGlite } from "@electric-sql/pglite"
import { electricSync } from "@electric-sql/pglite-sync"
// Create browser-based Postgres instance (persisted to OPFS)
export async function createLocalDB() {
const db = await PGlite.create("idb://my-app-db", {
extensions: { electric: electricSync() },
})
// Create local schema matching server
await db.exec(`
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// Sync from Electric into local PGlite
await db.electric.syncShapeToTable({
shape: {
url: `${process.env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`,
params: { table: "orders" },
},
table: "orders",
primaryKey: ["id"],
})
return db
}
// Query local database directly (works offline)
export async function queryLocalOrders(db: PGlite, customerId: string) {
const result = await db.query<Order>(
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC",
[customerId]
)
return result.rows
}
Electric Token API
// app/api/electric-token/route.ts — issue scoped Electric JWT
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { SignJWT } from "jose"
const ELECTRIC_SECRET = new TextEncoder().encode(process.env.ELECTRIC_SECRET!)
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Issue JWT scoped to the user's ID for Electric RLS
const token = await new SignJWT({
sub: session.user.id,
// Electric uses these claims to apply row-level security in shapes
"electric.user_id": session.user.id,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(ELECTRIC_SECRET)
return NextResponse.json({ token })
}
For the Convex real-time backend alternative that colocates server functions alongside reactive queries in a TypeScript-first backend without requiring a separate sync layer, see the Convex backend guide for reactive query patterns. For the Supabase Realtime alternative that provides PostgreSQL-backed real-time subscriptions through database change listeners rather than full offline sync — better for primarily online apps needing live updates without full local-first architecture, see the Supabase Advanced guide for realtime channels. The Claude Skills 360 bundle includes ElectricSQL skill sets covering shape definitions, PGlite, and offline patterns. Start with the free tier to try ElectricSQL sync configuration generation.