---
title: Serve over HTTP
description: Start a server node with serve, then talk to it with the typed client or
  the raw RPC client.
---

## Why serve over HTTP

Serve when several services or languages need to share the same boards, when you want horizontal scale-out through the router, or when callers should hold a capability token instead of catalog credentials. Skip it when one service owns its boards outright: embed the engine directly, and keep any write that needs to read first (a transaction) embedded too, since that combination isn't available over HTTP. See the [transactions guide](/guides/transactions) for why.

## Starting a node

`serve` from `lakefront/server` opens an `Engine` and puts an HTTP front end
on it. `ServeOptions` extends `OpenOptions` (see the
[full knob table](/reference/engine)) with three
additions: `port`, `nodeId`, and `auth`.

```ts serve.ts
import { serve } from "lakefront/server";

const node = await serve({
  postgres: "postgres://lakefront:lakefront@127.0.0.1:5432/lakefront",
  data: "./data",
  port: 8080,
});

console.log(`listening on ${node.url}`);
```

`port: 0` asks the OS to pick a free port. Read the assigned port and URL from the returned server:

```ts
const node = await serve({ postgres, data: "./data", port: 0 });
node.port; // the OS-assigned port
node.url; // http://127.0.0.1:<port>
```

`nodeId` defaults to `node-${process.pid}`; set it explicitly in anything with
more than one process, since it is what shows up in `node.stats()` and in a
query's `servedBy` field.

`auth` is covered on its own page. See
[the auth guide](/guides/auth). Leave it unset and every request
is permitted, which is the zero-config default the tour, the benchmarks, and
local development all run under.

The returned `Server` carries the `Engine` instance directly
(`node.engine`), so [operations](/guides/operations) like `flush`, `maintain`,
`stats`, and `evict` are reachable without a second connection to the lake.
`node.stop()` closes the HTTP server, waits for in-flight replica work to
settle, and closes the engine.

## Endpoints

A node exposes three routes:

| Route                  | Purpose                                                      |
| ---------------------- | ------------------------------------------------------------ |
| `GET /health`          | Liveness check: no DuckDB work, so a cold node still answers |
| `POST /rpc/:procedure` | Every read and write in the wire contract                    |
| `GET /watch`           | Server-sent-events change stream                             |

See the [wire protocol reference](/reference/wire-protocol) for request and
response formats, headers, and SSE events.

## Using the typed client

`connect` from `lakefront/client` wraps the wire contract in a Kysely query
builder and a set of mutation methods, scoped to one board at a time:

```ts client.ts
import { connect } from "lakefront/client";

const board = connect({ url: node.url }).board("acme_roadmap");

const counted = (await board.query("SELECT count(*) AS n FROM {{table}}")).unwrap("query");
const rows = await board.select(["id", "state"]).execute();

const written = await board.insert({ id: "i1", state: "active", title: "first item" });
written.unwrap("insert failed"); // -> the watermark it committed at
```

`query` is the engine's own read surface over the wire: same SQL, same outcome
shape. `select` is the query builder rooted at the board; both go through
`query.execute`. The mutation methods (`insert`, `insertMany`, `update`,
`updateMany`, `delete`, `deleteMany`) go through the corresponding `mutate.*`
procedures and each return `Result<number, WireError>`, where the number is the
watermark the write committed at.

Unwrap it with `.unwrap()` for a happy path, or check `isErr()` and branch
on `error._tag` when a caller needs to handle the failure.

`connect(...).board(id).atLeast(watermark)` returns a new client whose reads
refuse a replica older than that watermark: this is the read-your-write
mechanism. See the [consistency model](/concepts/consistency) for what that
guarantees.

The board client also exposes `watch()`, following the change feed over this
same connection. See the [watch guide](/guides/watch) for the
consumption pattern.

## Procedures BoardClient doesn't expose

`BoardClient` covers queries, the three mutation procedures (`insert`, `update`,
`delete`, each with a singular and a `Many` form), `stats()`, and `watch()`.
It does not expose `changes.read`. Reach for `createRpcClient` from
`lakefront/contract` directly when you need to page the change feed manually,
or any procedure the board client doesn't wrap:

```ts rpc.ts
import { createRpcClient, orThrow } from "lakefront/contract";

const rpc = createRpcClient({ url: node.url, boardId: "acme_roadmap" });
const page = await rpc["changes.read"]({ after: 0 });

// orThrow() unwraps every call instead of returning a Result -- useful in
// scripts and demos where a server-side failure is a bug, not a case to handle.
const stats = await orThrow(rpc)["node.stats"]({});
```

`createRpcClient` is also what `BoardClient` is built on, so its options
(`url`, `boardId`, `token`, `timeoutMs`, `retry`) are the same ones `connect`
accepts. `boardId` is optional when the token pins exactly one board; see
[the auth guide](/guides/auth) for that.

For the full watch-feed contract, including tail-only retention, the `resync`
event, and how to recover from one, see [watch()](/guides/watch).
