Skip to content

Quick start

Install nice-code, then build your first typed API call or your first piece of live shared state.

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.

Terminal window
# a typed API (actions) — build on errors + a Standard Schema library
bun add @nice-code/action @nice-code/error valibot
# live shared state (a realm-only app) — wire opens the connection, realm rides it
bun add @nice-code/realm @nice-code/wire
# both planes on one connection
bun add @nice-code/action @nice-code/realm @nice-code/error valibot
# the extras, as you reach for them
bun add @nice-code/state immer # a reactive store
bun add @nice-code/error # typed errors on their own

@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”
shared.ts
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”
server.ts
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 wsAcceptorCarrier alongside the HTTP one. Serving & Connecting → WebSockets has the Bun and Hono wiring.

Note for Edge/Serverless: This module-scope serveChannel is appropriate for long-lived Node and Bun processes. On a Cloudflare Worker, use serveWorker: coordinates and handlers can stay at module scope while runtime construction and crypto provisioning happen lazily on the first request. In a Durable Object, call serveDurableObject in 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”
client.ts
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 →.

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.

errors.ts
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,
}),
},
});
import { castNiceError } from "@nice-code/error";
throw err_user.fromId("not_found", { userId: "u_1" });
// On the receiving side — castNiceError always returns a NiceError
const caught = castNiceError(e);
if (err_user.isExact(caught) && caught.hasId("not_found")) {
const { userId } = caught.getContext("not_found"); // typed
}

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”
shared.ts
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”
client.ts
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 visitorId
realm.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.

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:

GameDO.ts
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.drop

That completes the loop. Serving a Realm → covers hosting, including outside Cloudflare, and Realm Mental Model → takes the concepts from here.

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.