Skip to content

Quick start

Install nice-code, define a typed error domain, and wire up your first action.

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, server push.
  • Live shared state — many clients watch and optimistically write one server-owned tree. That’s @nice-code/realm, and it needs no actions at all. Start here for multiplayer, presence, collaborative docs, live dashboards.

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 is just three files:

  • shared.ts — imported by both sides. Describes your actions, how they’re routed, and the runtime coordinates that label each side.
  • server.ts — the side that listens for calls (the acceptor).
  • client.ts — the side that dials in and makes calls (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
toConnector: [], // calls the server pushes to the client (none yet)
});

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 { ActionRuntime, 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";
const serverRuntime = new ActionRuntime(serverCoord);
// 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(serverRuntime, appChannel, {
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`

Want a live socket and server-to-client push? Add a wsAcceptorCarrier alongside the HTTP one — see Serving & Connecting → WebSockets for the Bun and Hono wiring.

Note for Edge/Serverless: This example builds the runtime and calls serveChannel in module/global scope, which is fine for a long-lived Node or Bun process. Edge runtimes like Cloudflare Workers and Durable Objects forbid I/O and random ID generation (e.g. crypto.randomUUID()) at the top level — code there only runs inside a request or inside the object’s lifecycle. In those environments, build the runtime and call serveChannel lazily, on the first request or in the Durable Object’s constructor, not in global scope. See Cloudflare Durable Objects → for the exact shape.

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,
transports: [
{ carrier: wsCarrier(() => ({ url: "wss://api.example.com/ws" })) },
{ carrier: httpCarrier(() => ({ url: "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’s the whole round trip. If you’d rather get a return value than a thrown error (check a result instead of writing 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 — and 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 — one server-owned tree that clients observe live and write optimistically, with the server enforcing the rules. A realm needs no actions: @nice-code/wire opens the connection, @nice-code/realm rides 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/gameRealm.ts
import { defineRealm, t } from "@nice-code/realm";
import { err_game } from "./errors"; // a typed domain, defined exactly like err_user below
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";
// One secure connection — no ActionRuntime, no channel. `storage` must be durable (see below).
const client = createWireClient({
identity: frontendCoord,
peer: serverCoord,
storage: createWebLocalStorageAdapter({ localStorage, keyPrefix: "game:" }),
transports: [{ carrier: wsCarrier(() => ({ url: "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
});
// `myId` is your avatar's persistentId (from the authenticated identity). The rule lets you write
// only your own entry — instant locally, confirmed (or rolled back) by the server.
realm.update((draft) => { draft.players[myId].x = 412; });

Every other connected client sees that write land live. With actions too? Open the connection with connectChannel instead and pass its connector to realmConnection(...) — the realm shares that one socket. Everything else is identical.

On Cloudflare, one call serves the sockets and hosts the realm — persistence and hibernation come free:

GameDO.ts
import { serveWireDurableObject } from "@nice-code/wire/platform/cloudflare";
import { hostRealm, serveRealmDurableObject } from "@nice-code/realm/platform/cloudflare";
import { gameRealm, serverCoord } from "./shared";
// inside your Durable Object:
const realms = serveRealmDurableObject(this.ctx, {
realms: { game_realm: hostRealm(gameRealm, { avatarType: "player" }) },
});
const server = serveWireDurableObject(this.ctx, {
identity: serverCoord.specify({ insId: this.ctx.id.toString() }),
protocols: [realms.protocol],
});
// forward: fetch → server.fetch · webSocketMessage → server.receive · close/error → server.drop

That’s the whole loop. Serving a Realm → covers hosting (including outside Cloudflare), and Realm Mental Model → takes the concepts from here.

Everything above is deployed live on Cloudflare — real Durable Objects, real hibernation, real 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.