Quick start
Install nice-code, then build your first typed API call or your first piece of live shared state.
Pick your first win
Section titled “Pick your first win”nice-code gives you two typed planes over one connection, and you can start with either:
- A typed API — call server functions from the client with full inference and typed errors. That’s @nice-code/action, below. Start here for RPC, forms, CRUD, or server push.
- Live shared state — one state tree the server owns, which many clients watch and write to optimistically. That’s @nice-code/realm, and it needs no actions at all. Start here for multiplayer, presence, collaborative docs, or live dashboards.
If you’re not sure, What are you building? maps a few project shapes to the modules they need. Either track below is a complete, runnable round trip.
Install
Section titled “Install”# a typed API (actions) — build on errors + a Standard Schema librarybun add @nice-code/action @nice-code/error valibot
# live shared state (a realm-only app) — wire opens the connection, realm rides itbun add @nice-code/realm @nice-code/wire
# both planes on one connectionbun add @nice-code/action @nice-code/realm @nice-code/error valibot
# the extras, as you reach for thembun add @nice-code/state immer # a reactive storebun add @nice-code/error # typed errors on their ownA typed API in five minutes
Section titled “A typed API in five minutes”@nice-code/action gives you typed calls across the network. A typical app has three files:
shared.ts— imported by both sides. It describes your actions, how they’re routed, and the runtime coordinates that label each side.server.ts— the side that listens for calls, known as the acceptor.client.ts— the side that dials in and makes calls, known as the connector.
Each action says which errors it can throw. The err_user used below is defined in
the error section further down.
1. Shared — your actions, routing, and coordinates
Section titled “1. Shared — your actions, routing, and coordinates”import { createActionRootDomain, actionSchema, defineChannel, RuntimeCoordinate } from "@nice-code/action";import * as v from "valibot";import { err_user } from "./errors";
// Runtime coordinates label each side. They live in shared code so BOTH sides// reference the exact same values — in a larger app, give them their own// `runtimeCoordinates.ts` (they're independent of how you channel your actions).export const serverCoord = RuntimeCoordinate.env("backend");export const frontendCoord = RuntimeCoordinate.env("frontend");
// A root domain is just a top-level namespace to hang your actions off of.export const act_app = createActionRootDomain({ domain: "act_app" });
// Group related actions into a domain. Both sides import this.export const act_user = act_app.createChildDomain({ domain: "act_user", actions: { getUser: actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ id: v.string(), name: v.string() }) }) .throws(err_user, ["not_found"]), },});
// A channel lists which actions travel in each direction.export const appChannel = defineChannel({ toAcceptor: [act_user], // calls the client sends to the server});2. Server — write the code that runs, then start listening
Section titled “2. Server — write the code that runs, then start listening”import { serveChannel, httpAcceptorCarrier } from "@nice-code/action";import { createMemoryStorageAdapter_json } from "@nice-code/util";import { appChannel, act_user, serverCoord } from "./shared";import { err_user } from "./errors";
// Fill in the actual implementation for each action in the domain.const userHandler = act_user.wrapAsLocalHandler({ getUser: async ({ userId }) => { const user = await db.users.find(userId); if (!user) throw err_user.fromId("not_found", { userId }); return user; },});
// Start listening. One HTTP carrier is the whole backend — `server.fetch` is a standard// web fetch handler, so Bun / Deno / Workers serve a default `{ fetch }` export directly.const server = serveChannel(appChannel, { identity: serverCoord, storage: createMemoryStorageAdapter_json(), // holds the server identity + trusted-client key pins — // in-memory is fine for dev; use persistent storage in // production or a restart forgets both handlers: [userHandler], carriers: [httpAcceptorCarrier()],});
export default { fetch: server.fetch }; // run it: `bun run server.ts`For a live socket and server-to-client push, add a
wsAcceptorCarrieralongside the HTTP one. Serving & Connecting → WebSockets has the Bun and Hono wiring.
Note for Edge/Serverless: This module-scope
serveChannelis appropriate for long-lived Node and Bun processes. On a Cloudflare Worker, useserveWorker: coordinates and handlers can stay at module scope while runtime construction and crypto provisioning happen lazily on the first request. In a Durable Object, callserveDurableObjectin the object constructor. See Cloudflare Durable Objects → for the exact shapes.
3. Client — connect once, then call from anywhere
Section titled “3. Client — connect once, then call from anywhere”import { ActionRuntime, connectChannel, wsCarrier, httpCarrier } from "@nice-code/action";import { createWebLocalStorageAdapter } from "@nice-code/util";import { appChannel, act_user, serverCoord, frontendCoord } from "./shared";
const clientRuntime = new ActionRuntime(frontendCoord);
// Holds this client's crypto identity and the server keys it has met (see Security levels).// Must be a *durable* store — the server pins this client's verify key on first contact, so an// identity that regenerates every page load is rejected after the first reload. Memory adapters// are for tests only.const storage = createWebLocalStorageAdapter({ localStorage, keyPrefix: "client:" });
// Connect to the server. Try WebSocket first, fall back to HTTP.connectChannel(clientRuntime, appChannel, { peer: serverCoord, storage, carriers: [ wsCarrier("wss://api.example.com/ws"), httpCarrier("https://api.example.com/action"), ],});
// Call the action. You get back a typed `user`.const user = await act_user.action.getUser .request({ userId: "u_1" }) .runToOutput();That completes the round trip. If you’d rather check a returned result than write try/catch,
see Error Handling →.
@nice-code/error in two minutes
Section titled “@nice-code/error in two minutes”This is the error domain the action above throws from. You describe each error once: its name, its extra data, and its HTTP status. From then on it’s fully typed both where you throw it and where you catch it.
Define a domain
Section titled “Define a domain”import { defineNiceError, err } from "@nice-code/error";
export const err_user = defineNiceError({ domain: "err_user", schema: { not_found: err<{ userId: string }>({ message: ({ userId }) => `User not found: ${userId}`, httpStatusCode: 404, context: { required: true }, }), account_locked: err({ message: "Account is locked", httpStatusCode: 403, }), },});Throw, catch, narrow
Section titled “Throw, catch, narrow”import { castNiceError } from "@nice-code/error";
throw err_user.fromId("not_found", { userId: "u_1" });
// On the receiving side — castNiceError always returns a NiceErrorconst caught = castNiceError(e);if (err_user.isExact(caught) && caught.hasId("not_found")) { const { userId } = caught.getContext("not_found"); // typed}Live shared state in three minutes
Section titled “Live shared state in three minutes”Actions are for things that happen. A realm is for state that many parties watch. The server owns one state tree and enforces its rules; clients observe it live and write to it optimistically. A realm needs no actions: @nice-code/wire opens the connection and @nice-code/realm uses it. You attach as an avatar, your identity inside the realm.
1. Define the realm — imported by client and server
Section titled “1. Define the realm — imported by client and server”import { defineRealm, t } from "@nice-code/realm";import { RuntimeCoordinate } from "@nice-code/wire";import { err_game } from "./errors"; // a typed domain, defined exactly like err_user above
export const serverCoord = RuntimeCoordinate.env("backend");export const frontendCoord = RuntimeCoordinate.env("frontend");
export const gameRealm = defineRealm({ id: "game_realm", avatars: { player: { persistentId: t.string() } }, state: { players: t.record(t.id("playerId"), t.object({ x: t.number(), y: t.number() })), }, rules: (r) => [ r.path("players.$playerId.{x,y}") .view(r.everyone) // everyone sees every player .alter(({ avatar, params }) => // only you may move yours avatar.persistentId === params.playerId || err_game.fromId("not_yours")), ],});2. Client — open the connection, attach, write
Section titled “2. Client — open the connection, attach, write”import { createWireClient, wsCarrier } from "@nice-code/wire";import { connectRealm, realmConnection } from "@nice-code/realm";import { createWebLocalStorageAdapter } from "@nice-code/util";import { gameRealm, serverCoord, frontendCoord } from "./shared";
// A stable id for this browser. It becomes your avatar's persistentId.const visitorId = (localStorage.visitorId ??= crypto.randomUUID());
// One secure connection — no ActionRuntime, no channel. `storage` must be durable (see below).const client = createWireClient({ identity: frontendCoord.withPersistentId(visitorId), peer: serverCoord, storage: createWebLocalStorageAdapter({ localStorage, keyPrefix: "game:" }), carriers: [wsCarrier("wss://api.example.com/realm/ws")],});
const realm = connectRealm(gameRealm, { avatar: { type: "player" }, // identity defaults from the secure handshake connection: realmConnection(client), // ← the wire client above; no actions needed // Runs every time you attach. It creates your entry if the server doesn't have it. assertOnAttach: (draft) => { draft.players[visitorId] ??= { x: 0, y: 0 }; },});
// `connectRealm` attaches the protocol; the wire client owns dialing.await client.connect();
// `realm.avatar` is who you are in this realm. The rule lets you write only your own entry —// instant locally, confirmed (or rolled back) by the server.const me = realm.avatar.persistentId; // same value as visitorIdrealm.update((draft) => { draft.players[me].x = 412; });You can write straight away. A write made before the first state arrives waits for it, then runs
after assertOnAttach, so draft.players[me] is already there.
There is no separate “join” step. Writing a whole entry is how it comes to exist, and the same
{x,y} rule checks it field by field. Every other connected client sees that write arrive live. If your app uses actions too, open the
connection with connectChannel instead and pass its connector to realmConnection(...). The realm
then shares that one socket, and everything else is identical.
3. Server — host the realm
Section titled “3. Server — host the realm”On Cloudflare, two focused helpers compose the host: createRealmHost owns realm state and persistence,
while serveWireDurableObject owns the secure hibernatable sockets. Your Durable Object only forwards
platform events to the returned handles:
import { serveWireDurableObject } from "@nice-code/wire/platform/cloudflare";import { hostRealm, createRealmHost } from "@nice-code/realm/platform/cloudflare";import { gameRealm, serverCoord } from "./shared";
// inside your Durable Object:const realms = createRealmHost(this.ctx, { realms: { game_realm: hostRealm(gameRealm, { avatarType: "player", engine: { initialState: { players: {} } }, }), },});const server = serveWireDurableObject(this.ctx, { identity: serverCoord.withPersistentId(this.ctx.id.toString()), protocols: [realms.protocol],});// forward: fetch → server.fetch · webSocketMessage → server.receive · close/error → server.dropThat completes the loop. Serving a Realm → covers hosting, including outside Cloudflare, and Realm Mental Model → takes the concepts from here.
See it running
Section titled “See it running”Everything above is deployed on Cloudflare, with real Durable Objects, hibernation and reconnects. Open one in two tabs and watch the same realm from both sides:
- Tank Shooter — a real-time multiplayer arena on one realm. Reload mid-match and it drops you straight back in.
- Pixel Plaza — a shared pixel world with live presence.
- The playground — every feature in one place: actions over HTTP and WebSocket at three security levels, live realm boards, stores, and typed errors.