# logux/client: the browser side of a log based sync protocol

> Logux replaces request and response with an operation log that the client, the server and other clients all synchronise, and the @logux/client package is the browser half of that idea: a client that talks over WebSocket, an IndexedDB and SQL log store, a CRDT table layer, and small widgets for showing sync status. It is MIT licensed, actively released, and the least familiar thing about it is the mental model.

**logux/client** — Logux base components to build web client

- Repository: https://github.com/logux/client
- Website: https://logux.org/
- Stars: 750 · Forks: 50
- Language: TypeScript
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/logux-client

## The idea underneath: a log of operations instead of requests

Logux is a new way to connect client and server. Instead of sending HTTP requests, for example AJAX and GraphQL, it synchronizes a log of operations between client, server, and other clients.

That single sentence contains a lot of consequences. A log is append-only and ordered, so every participant can replay it and reach the same state, which is why the same log can be synchronised with the server and with other clients rather than only with a server. It is also why the conflicts have to be resolved by a rule rather than by the server, since there may be no single authority ordering the writes.

This package is the base components to build a web client, and the repository lists what is in it. CrossTabClient and Client create the web client. IndexedStore stores the Logux log in IndexedDB. SqlLogStore stores the log in SQLite or PGlite. Then there is the UI layer: badge() to show synchronisation status, status() to write your own UI for the same thing, attention(), confirm() and favicon() to improve the experience in a Logux web app, and log() to print the synchronisation status to the browser DevTools.

The last one is a small detail that says something about the project. Printing sync status to the console is the kind of affordance you add when you expect people to need to debug it, which is a sign of a protocol that is not yet obvious to everyone using it.

The project is MIT licensed, the author is Andrey Sitnik, and it is built by Evil Martians, a consultancy working on developer tools, AI and cybersecurity startups. The site is logux.org, and there is a demo page showing the widget UI.

## CrossTabClient and the shape of a Logux client

The usage example is short and shows the whole configuration surface of a client.

```js
import { CrossTabClient, badge, badgeEn, log } from '@logux/client'
import { badgeStyles } from '@logux/client/badge/styles'

let userId = document.querySelector('meta[name=user]').content
let token = document.querySelector('meta[name=token]').content

const client = new CrossTabClient({
  subprotocol: 1,
  server: 'wss://example.com:1337',
  userId,
  token
})

badge(client, { messages: badgeEn, styles: badgeStyles })
log(client)

client.start()
```

The transport is a WebSocket URL, and the credentials are a user identifier and a token, both read from meta tags in the page. The subprotocol version is passed explicitly, which is the sort of thing you want pinned when the wire format can change.

The name CrossTabClient is the interesting part, and it is a design decision rather than a name. A cross tab client coordinates between browser tabs, so an action taken in one tab reaches the others through a shared channel rather than through the server. That is the difference between the two client types in this package, and it is a real feature: without it, every tab would hold its own log and the same edit made in two tabs would be a conflict the server has to arbitrate.

The UI wiring is three lines. badge attaches the status widget and takes a messages object and a styles object, so you can localise the widget text and restyle it. log attaches the DevTools output. Then client.start() begins the synchronisation, which is separate from the construction so you can attach listeners before anything happens.

The package is installed alongside two others, and the reason is in the example itself, since badgeStyles comes from a subpath of the same package but the messages come from elsewhere:

```sh
npm install @logux/core @logux/client nanostores
```

@logux/core holds the protocol logic, and nanostores is the state library the client is built on. The runtime dependencies confirm that: @logux/actions, @logux/core, nanoevents for the event emitter and nanoid for identifiers.

## The SQL layer, and why the conflict rule is a choice you make

The db entry point keeps CRDT tables in a local SQL database, like SQLite in the browser, filled from the Logux log. All changes are synchronised as Logux actions, and edit conflicts are resolved with per-field last write wins.

That last clause is the design decision to notice. Per-field last write wins means that when two clients change different fields of the same record, both changes survive, and when they change the same field, the last one wins. That is a much better outcome than a whole-record last write wins, and it is worse than a merge that understands the meaning of the field.

The setup is built on Nano Stores SQL:

```sh
npm install @nanostores/sql
```

and the table definition is a schema plus an index spec:

```js
let user = crdt.table(
  'user',
  {
    age: optional(number()),
    createdAt: bigint({ default: () => Date.now() }),
    name: string()
  },
  ['name', ['age', 'createdAt DESC']]
)
```

The index argument is flexible: a column, an array of columns, an object with columns and unique, or the whole CREATE INDEX statement in an sql property. And the query API is a tagged template:

```js
let $adults = user.select`WHERE "age" >= ${18} ORDER BY "name"`
```

Two operations deserve attention because they are about how actions are grouped, not about SQL. Arrays create batch actions, applied in a single SQL query, so user.create with an array of two records is one action rather than two. And the change method on a custom action keeps per-field last write wins, unlike your own UPDATE, which is the point of the whole layer.

The two lifecycle callbacks in the constructor are about the reality of a local database. migrating gives you a hook to show a loader until the migration promise is done, and repeat asks the server for the full client log, which you remove if you store the whole log locally. So the library is telling you that a local-first app has two modes and you have to say which one you are in.

## Custom actions, and where the type system comes from

Custom actions are applied to the database by your callback, and the action types in that callback come from defineAction().

```ts
import { defineAction } from '@logux/actions'

let userRenamed = defineAction<{
  id: string
  name: string
  type: 'user/renamed'
}>('user/renamed')

let renameUser = crdt.action(userRenamed, async (tx, action, meta) => {
  // change() keeps per-field last write wins, unlike your own UPDATE
  await user.change(tx, action.id, { name: action.name }, meta)
})

await renameUser({ id, name: 'New' })
```

The type parameter is the whole type of the action payload, and the string argument is the action type on the wire. The action creator returns something you call with the payload, and the callback receives a transaction, the action and some metadata.

The important instruction is in the comment. The change method keeps per-field last write wins, unlike your own UPDATE. That is the library protecting the conflict policy you adopted, and it means the obvious way to write the callback, a raw UPDATE statement, would quietly break the semantics the rest of the layer depends on.

There is also a CRDT table layer of its own, create-crdt-table/ in the repository, alongside create-client-store/, create-filter/ and create-reducer/ which are the older Logux building blocks, and sync-map-template/ which is the state synchronisation helper.

And the same database can keep the Logux log itself, which is the difference between the two store options:

```js
import { CrossTabClient } from '@logux/client'
import { SqlLogStore } from '@logux/client/db'

const client = new CrossTabClient({
  …,
  store: new SqlLogStore(db)
})
```

So the log can live in IndexedDB through IndexedStore or in SQL through SqlLogStore, and the choice determines whether the local state is queryable with SQL.

## End-to-end encryption, and the action types you have to leave readable

The encryption story is short, which does not mean it is simple.

encryptActions() encrypts actions before sending them to the server, so the server cannot read users' data. You pass a password or an AES CryptoKey and list the action types to be kept unencrypted:

```js
import { encryptActions } from '@logux/client'

encryptActions(client, localStorage.getItem('userPassword'), {
  ignore: ['server/public']
})
```

The ignore list is the part that needs thought. Encryption happens per action type, and the server still has to understand some actions, because it is the party that has to enforce things like permissions and issue the action that tells the client to do something. An action type of server/public in the example is one the server must be able to read.

So the model is partial encryption by action type, with an explicit allowlist of what stays in the clear. That is more honest than a claim of full end-to-end encryption, because in a log based protocol with server-issued actions, total encryption would mean the server could not run the protocol at all.

The password comes from localStorage in the example, which is a place to be careful with, and the alternative the API allows is an AES CryptoKey, which points at a flow where the key is derived or fetched rather than stored alongside the data.

The encrypt-actions/ directory holds the implementation, and the package exports at the root of package.json show the entry point structure that makes all of this tree-shakeable: the root, ./db, ./vue, ./react, ./preact, ./package.json and ./badge/styles, with sideEffects set to false.

## The repository, the release cadence and who maintains it

The repository is a pnpm workspace with one published package, @logux/client, at version 0.27.0. The release history is regular and recent: 0.27.0 on 2026-10-16, 0.26.0 on 2026-10-06 and 0.25.2 on 2026-09-12, with the last push to the repository on 2026-10-16.

The version numbers being below 1.0 is worth noticing. This is a young protocol in the sense that its API is still moving, and the log model is unusual enough that the library surface will probably keep changing.

The top level of the repository is one directory per feature, which is the clearest signal of how the library is organised. There is client/ and cross-tab-client/ for the two client types, indexed-store/ and sql-log-store/ for the two stores, create-crdt-table/, create-client-store/, create-filter/, create-reducer/ and sync-map-template/ for the building blocks, encrypt-actions/ for the encryption layer, and then the UI pieces as separate directories: attention/, badge/, confirm/, favicon/, log/ and status/.

There are also framework bindings, with vue/, react/ and preact/ directories, each of which has its own nanostores adapter in the dev dependencies. The logux-undo-error/ directory is a small piece of the kind of detail that shows the library has been used in real applications.

The test and lint setup is a good sign. The test script runs everything matching test:, which covers lint with oxlint, a size-limit check, a check-dts type test, a build test, and coverage through vitest. There is a separate oxfmt.config.ts and oxlint.config.ts, so the project has moved to the Oxc toolchain, and a pnpm-workspace.yaml alongside the lockfile.

There is a .devcontainer.json, which is the kind of thing a project adds when it wants contributors to be able to start without setting up a local toolchain, and the test/ directory holds a demo built with vite, since the build script is a vite build of the demo and the start script runs it with the browser opening automatically.

## Conclusion

Adopt @logux/client if you are building a web application that is offline first or that needs several clients to see each other's edits without a round trip, since that is the model it implements and the CRDT table layer handles the conflicts. Do not adopt it if your application is a straightforward request and response API, because the log model and the action vocabulary add real complexity for a case that does not need it. Before you start, read the Logux guide and the architecture pages, decide whether you want the IndexedDB store or the SQL store, and check the conflict resolution rule, because per-field last write wins is a policy you are choosing rather than one you inherit.

## FAQ

### What is Logux and how is it different from HTTP or GraphQL?

Logux synchronises a log of operations between the client, the server and other clients, instead of sending HTTP requests. Because the log is ordered and append-only, every participant can replay it to reach the same state, which is what allows client to client synchronisation and local-first applications.

### How do conflicts get resolved in the Logux SQL tables?

With per-field last write wins. Changes to different fields of the same record both survive, and changes to the same field are resolved by taking the last one, and the change method on a custom action preserves that rule, unlike writing your own UPDATE statement.

### What is the difference between CrossTabClient and Client?

CrossTabClient coordinates between browser tabs, so an action taken in one tab reaches the others through a shared channel rather than through the server. The other building blocks are IndexedStore for the log in IndexedDB, SqlLogStore for SQLite or PGlite, and the badge, status, attention, confirm, favicon and log helpers for the user interface.

### Does @logux/client support end-to-end encryption?

Yes, through encryptActions(), which encrypts actions before sending them to the server so the server cannot read the data. You pass a password or an AES CryptoKey and list the action types to leave unencrypted, such as server/public, because the server still has to read some actions to run the protocol.

## Sources

- [License: MIT](https://github.com/logux/client/blob/main/LICENSE)
- [logux/client on GitHub](https://github.com/logux/client)
- [Project website](https://logux.org/)
- [README](https://github.com/logux/client/blob/main/README.md)
- [Releases](https://github.com/logux/client/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/logux-client
