Self-hosted real-time CRDT sync server.
Open-source alternative to Liveblocks and PartyKit β no vendor lock-in, no merge conflicts.
Built on Effect TS β type-safe errors, composable async, runtime schema validation.
Meridian is a real-time CRDT sync server that runs self-hosted on native infrastructure or at the edge on Cloudflare Workers β same SDK, same protocol, two deployment targets.
You pick a CRDT type (counter, set, register, presence), apply operations from any client, and every client converges to the same value automatically β no locks, no last-write-wins bugs.
# Start the server
MERIDIAN_SIGNING_KEY=$(openssl rand -hex 32) docker compose up -d
# Issue a token
curl -X POST http://localhost:3000/v1/namespaces/my-room/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"client_id": 1, "ttl_ms": 3600000}'import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
import { MeridianProvider, useAwareness, useGCounter } from "meridian-react";
import { Schema } from "effect";
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });
function Room() {
const { value, increment } = useGCounter("gc:views");
// Awareness: ephemeral cursor positions, not persisted
const { peers, update } = useAwareness("cursors", CursorSchema);
return <p>{value} views Β· {peers.length} peers live</p>;
}
function App() {
return (
<MeridianProvider client={client}>
<Room />
</MeridianProvider>
);
}The SDK exposes a simple imperative API on top of an Effect-based core β use runPromise to bridge into your existing async code, or compose with Effect.gen for full type-safe pipelines.
import { MeridianClient } from "meridian-sdk";
import { Effect, Schema } from "effect";
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
// Counters, registers, sets β all conflict-free
const views = client.gcounter("gc:views");
views.increment(1);
views.onChange(v => console.log("views:", v));
// Every handle has stream() β composable with Effect
import { Stream } from "effect";
await Effect.runPromise(
views.stream().pipe(Stream.take(5), Stream.runForEach(v => Effect.log(`views: ${v}`)))
);
// Awareness β ephemeral cursors, not persisted, schema-validated at runtime
const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });
const cursors = client.awareness("cursors", CursorSchema);
cursors.update({ x: 120, y: 80 });
cursors.onChange(peers => console.log("live cursors:", peers));
client.close();| Type | Use case | Example key |
|---|---|---|
GCounter |
Page views, likes | gc:views |
PNCounter |
Inventory, votes | pn:stock |
ORSet |
Shopping cart, tags | or:cart |
LwwRegister |
User profile, config | lw:title |
Presence |
Who's online, visitor count | pr:room |
CRDTMap |
Structured document with typed fields | cm:doc |
RGA |
Ordered sequence β collaborative text editing | rg:doc |
TreeCRDT |
Hierarchical tree β outlines, document trees, mind maps | tr:outline |
Non-CRDT ephemeral channel for high-frequency transient state (cursors, selections, "is typing"). Updates fan out in real time but are never persisted β use Presence when you need durability and TTL-based cleanup, Awareness when you need raw speed.
CRDTMap lets you assign a different CRDT type to each key within a single document. Each key merges independently using its own conflict resolution semantics.
RGA (Replicated Growable Array) is Meridian's ordered-sequence CRDT β the same algorithm behind collaborative editors like Google Docs. Every character has a stable, unique identity across all peers, so concurrent insertions and deletions converge without conflict.
const doc = client.rga("rg:document");
// Insert text at position 5 (0-indexed character offset)
doc.insert(5, "Hello");
// Delete 3 characters starting at position 2
doc.delete(2, 3);
// Read the current text
const { text } = doc.value();
// React to remote changes
doc.onChange(({ text }) => editor.setValue(text));// React
const { text, insert, delete: del } = useRga("rg:document");Concurrent edits from multiple clients are merged automatically β the final text is identical on every peer regardless of arrival order.
TreeCRDT implements the Kleppmann et al. 2021 move-operation algorithm β the only known CRDT that handles concurrent move operations correctly. This makes it suitable for outlines, task hierarchies, document trees, and mind maps where nodes are frequently reorganized.
const tree = client.tree("tr:outline");
// Create nodes
const root = tree.addNode(null, { title: "Project" });
const task1 = tree.addNode(root, { title: "Research" });
const task2 = tree.addNode(root, { title: "Implementation" });
// Move a node to a different parent β safe under concurrent moves
tree.move(task2, task1);
// Read the current tree
const { roots } = tree.value();
// roots = [{ id, data, children: [...] }]
// React to remote changes
tree.onChange(({ roots }) => renderTree(roots));// React
const { roots, addNode, move } = useTree("tr:outline");Concurrent moves (e.g. two peers moving the same node to different parents at the same time) are resolved deterministically β no cycles, no lost nodes.
Aggregate data across multiple CRDTs in a single request β no need to read them one by one.
// Sum all page view counters matching a glob pattern
const result = await client.query({ from: "gc:views-*", aggregate: "sum" });
console.log(result.value); // total across all matched GCounters
// Union all shopping carts
const carts = await client.query({ from: "or:cart-*", aggregate: "union" });
// Latest config value across regions
const config = await client.query({ from: "lw:config-*", aggregate: "latest" });Or reactively in React:
const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useQuery(spec);See the Query Engine docs for the full aggregation table and where clause filters.
Subscribe once β get a push every time matching CRDTs change. No polling, no manual re-fetch.
const handle = client.liveQuery({ from: "gc:views-*", aggregate: "sum" });
handle.onResult(result => console.log("live total:", result.value));
// Cancel
handle.close();Or in React β useLiveQuery connects on mount, updates on every delta, and disconnects on unmount:
const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useLiveQuery(spec);The SDK re-sends subscriptions automatically after a WebSocket reconnect. Set type to avoid re-executing queries for unrelated CRDT deltas:
// Only re-executes when a GCounter changes β skips ORSet/LwwRegister deltas
client.liveQuery({ from: "gc:views-*", type: "gcounter", aggregate: "sum" });Deploy Meridian to the edge in minutes β no server, no Docker, no ops.
cd crates/meridian-edge
cp .dev.vars.example .dev.vars # add your MERIDIAN_SIGNING_KEY
wrangler dev # local dev
wrangler deploy # production on CloudflareThe edge runtime uses Durable Objects for per-namespace state (replaces sled), compiles to WASM via wasm-bindgen, and exposes the exact same WebSocket + REST API as the native server. Your SDK client connects to either without any code change:
import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
// Native server
const client = await Effect.runPromise(
MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);
// Edge worker (same SDK, different URL)
const client = await Effect.runPromise(
MeridianClient.create({ url: "https://my-worker.workers.dev", namespace: "my-room", token })
);See crates/meridian-edge/ for full setup.
npm
| Package | Description |
|---|---|
meridian-sdk |
TypeScript SDK β Effect-based, msgpack, fully typed. Includes stream() on all CRDT handles and MeridianLive Layer for DI |
meridian-react |
React hooks β useGCounter, usePresence, useAwareness, etc. |
meridian-devtools |
Devtools panel β CRDT inspector, event stream, WAL history, op latency P50/P99 |
meridian-cli |
CLI β meridian inspect and meridian replay for terminal-based debugging |
Rust crates
| Crate | Description |
|---|---|
server |
Native binary β axum + tokio |
meridian-core |
Shared logic β CRDTs, auth, protocol (no runtime dep) |
meridian-edge |
Cloudflare Workers runtime β WASM, Durable Objects |
meridian-storage |
Pluggable storage backends β sled, PostgreSQL, Redis, in-memory; S3 WAL archive (--features wal-archive-s3) |
meridian-cluster |
Multinode clustering β Redis Pub/Sub + HTTP push transport |
| Variable | Default | Description |
|---|---|---|
MERIDIAN_BIND |
0.0.0.0:3000 |
TCP bind address |
MERIDIAN_DATA_DIR |
./data |
sled storage path |
MERIDIAN_SIGNING_KEY |
(random) | 32-byte hex ed25519 seed |
MERIDIAN_WEBHOOK_URL |
(unset) | Webhook endpoint URL |
MERIDIAN_WEBHOOK_SECRET |
(unset) | HMAC-SHA256 signing secret |
REDIS_URL |
(unset) | Redis URL β enables cluster mode (--features cluster) |
MERIDIAN_PEERS |
(unset) | Comma-separated peer URLs β enables HTTP cluster mode (--features cluster-http) |
MERIDIAN_NODE_ID |
(auto) | Unique node ID β auto-derived from hostname+port if unset |
MERIDIAN_ANTI_ENTROPY_SECS |
30 |
Gossip interval in seconds |
S3_BUCKET |
(unset) | Enable S3 WAL archive β bucket name (--features wal-archive-s3) |
S3_ENDPOINT |
(unset) | S3-compatible endpoint override (R2, MinIO, LocalStack) |
S3_REGION |
us-east-1 |
AWS region |
S3_KEY_PREFIX |
wal/ |
Object key prefix for WAL segments |
WAL_SEGMENT_SIZE |
500 |
Number of WAL entries per S3 segment |