Stoat for Web: the Solid.js client behind the Stoat chat app
Stoat for Web – browser app for Stoat
At a glance
- What is it?
- Stoat for Web is the official browser client for the Stoat chat service, written in TypeScript with Solid.js and organized as a pnpm workspace with a separate stoat.js SDK package. It is deployed as a static bundle with runtime-injected environment variables, and the client can point at a local backend or fall back to the hosted one.
- Who is it for?
- Stoat for Web is worth reading or self-hosting if you run a Stoat instance and want a web client you control, since the build produces a static directory and the backend address is decided by environment variables at runtime rather than compiled in. It is the wrong tool if you want a general purpose chat client for other services, because the whole surface is bound to the Stoat API through the stoat.js package.
- Can I use it commercially?
- Yes, with strict conditions. AGPL-3.0 is a network copyleft licence: if people use a modified version over a network, for example as a hosted service, you must offer them its source code under the same licence.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A pnpm workspace where the SDK is a separate package
Stoat for Web is described as the official web client powering stoat.chat/app, built with Solid.js. The repository name is for-web and the package name is stoat-for-web.
The layout is a monorepo built on pnpm, with a pnpm-workspace.yaml and a pnpm-lock.yaml at the root, a packages/ directory holding the workspace members, and a patches/ directory. The root package.json is marked private and pins the package manager to a specific pnpm release with an integrity hash, which is the pattern that keeps a workspace reproducible across machines.
Inside packages/ sits a client package and a stoat.js package. The Dockerfile names the sub-dependencies explicitly when it builds them, listing stoat.js, solid-livekit-components, the lingui plugins and panda css, which tells you that the SDK is a first-class workspace member rather than an external dependency, and that live kit components, that is the video calling pieces, are also built from source inside the tree.
That structure is the main architectural decision here. Separating the API client from the user interface means the protocol layer can be updated and tested on its own, and it means a fork of the interface does not require forking the protocol code.
The repository also carries the tools a team would expect. There is a .mise/ directory driving the task runner, a Dockerfile with docker/ and lifecycle/ directories, a vercel.json for hosting on Vercel, a flake.nix and flake.lock for a Nix development environment, git-town.toml for branch workflow, renovate.json for dependency updates, and release-please-config.json with a manifest for release automation.
mise drives the whole workflow from install to CI check
The development guide asks contributors to read the contribution guidelines, the code style guidelines in GUIDELINES.md and the technical documentation before starting, and to install Git and mise-en-place.
Everything after that is a mise task. The setup sequence clones the repository with submodules, installs all packages, builds the dependencies, copies the environment file, starts the dev server and runs the checks:
# clone the repository
git clone --recursive https://github.com/stoatchat/for-web client
cd client
# update submodules if you pull new changes
# git submodule init && git submodule update
# install all packages
mise install:frozen
# build deps:
mise build:depsThe rest of the sequence covers the environment file, the dev server and the checks:
# customise the .env
cp packages/client/.env.example packages/client/.env
# run dev server
mise dev
# run all CI checks locally
mise checkThe dev server then serves the client at local.revolt.chat:5173, and the local hostname is not a typo, it is the local Stoat instance the client expects to talk to.
Two details are worth pulling out. The clone uses --recursive because there are submodules, and the commented-out submodule update line is there for when you pull new changes later, which is a common source of confusion when a workspace depends on git submodules for part of its tree. And mise check runs the same checks as CI locally, which is the point of a task runner of this kind: the linting, formatting and type checks configured through eslint, prettier and typescript-eslint can be run before pushing rather than after.
The engine requirements in the root package.json are modest, node 16 or above and pnpm 6 or above, even though the pinned package manager is much newer, so the runtime floor is not what constrains you.
The backend URL is a runtime variable, not a build-time constant
By default the client connects to a backend running on the same host, on localhost. To point it at the official hosted backend instead, the README says to open the .env file at packages/client/.env and comment out the local URL variables:
# connect to local Stoat instance
#VITE_API_URL=http://localhost:14702When those variables are not set, the client automatically falls back to the official backend, and the README points at a specific file for the logic, packages/client/components/common/lib/env.ts.
The deployment picture follows from that. The Dockerfile declares placeholder values for the environment rather than real ones, and the comments explain why: they are replaced by inject.js at container run startup.
ENV VITE_HOST=__VITE_HOST__
ENV VITE_API_URL=__VITE_API_URL__
ENV VITE_DEV_WS_URL=__VITE_WS_URL__
ENV VITE_DEV_MEDIA_URL=__VITE_MEDIA_URL__
ENV VITE_DEV_PROXY_URL=__VITE_PROXY_URL__
ENV VITE_DEV_GIFBOX_URL=__VITE_GIFBOX_URL__
ENV VITE_RNNOISE_WORKLET_CDN_URL=__VITE_RNNOISE_WORKLET_CDN_URL__That is a deliberate design for a self-hosted service. One image can be built once and then pointed at any backend, which means the same artifact works for the official deployment and for a private instance, and an operator changes configuration by setting environment variables at run time rather than rebuilding.
It also means the deployment has a moving part that is easy to get wrong. If the injection step does not run or the variable names do not match, the client is left with placeholders, and the failure appears as a client that loads but cannot reach its API. The base path and the PWA scope are build arguments in the same Dockerfile, so a deployment under a sub-path is a build-time decision while the backend address is a run-time one, and it is worth keeping those two apart when you plan an install.
Ten routes have to exist on whatever host you deploy to
The deployment guide is short, because the client builds to a static directory. After the install, dependency build and web build, with a separate production build target for Stoat production, you deploy the directory packages/client/dist.
What is not optional is the routing. The app needs ten routes served correctly:
/login
/pwa
/dev
/discover
/settings
/invite
/bot
/friends
/server
/channelThe README ties this list to a specific place in the source, Content.tsx#L33 in packages/client/src/index.tsx, which is where the router is defined. The reason the list exists is that this is a single page application: the paths are handled client side, so a static host has to serve the same entry document for each of them rather than returning a 404 for the deeper ones.
The set of paths also describes the product. There is a login route, a PWA route, a developer route, a discover route for browsing servers, settings, an invite route for accepting server invitations, a bot route for managing bot accounts, a friends route for the friend list, and the server and channel routes that carry the actual chat.
A second path appears in the build configuration, the base path argument and the PWA scope, both of which are build-time values. The PWA scope matters because it determines the directory the installed progressive web app is served from, and if the scope and the base path disagree the install will register at the wrong origin.
For an operator, the practical checklist is that the host rewrites the ten paths to the client entry point, that the base path and PWA scope are set consistently at build time, and that the runtime injection supplies real values for the seven environment variables.
The container build compiles lingui catalogues and panda styles before bundling
The Dockerfile is a two stage build, and the first stage is where the complexity lives. It starts from node:24-alpine, adds git, python3, make and a C++ compiler, and enables pnpm through corepack with the pinned version.
The dependency build step names the packages it compiles:
RUN pnpm --filter stoat.js build && \
pnpm --filter solid-livekit-components build && \
pnpm --filter client exec lingui compile --typescript && \
pnpm --filter client exec node scripts/copyAssets.mjs && \
pnpm --filter client exec panda codegenThree separate toolchains run here. Lingui handles internationalisation, and the extract and compile commands are run again later in the file, once after the dependency build and once more on their own, which suggests the catalogue is regenerated after the assets are in place. Panda generates the CSS from its configuration rather than shipping hand-written stylesheets. The copyAssets script brings static files into the build.
The Vite build itself is given a larger heap, and the Dockerfile explains why: the plugin-legacy polyfill detection needs more than the Node default. That is a comment worth reading, because the failure it prevents is a build that runs out of memory during a stage that looks like it should be cheap.
The second stage is described as a minimal runtime image, and the truncated listing shows it starting from the same node:24-alpine base. The design goal is obvious from the two stage layout: the build stage needs a compiler toolchain, and the runtime stage does not, so the compiler never ships with the application.
The Node 24 base also puts the root package.json engine floor of node 16 or above in perspective. The declared engines describe what the source nominally supports, while the image is built on a much newer runtime.
AGPL-3.0, a fast release cadence and a shared roadmap
The licence is AGPL-3.0, and for a client that talks to a chat service the network copyleft clause is the part to think about. The AGPL extends the copyleft obligation to software offered over a network, so if you modify this client and serve it to users, you are obliged to offer the corresponding source of your modifications. For a self-hosted instance of a community chat client, that is often exactly the intent, but it is a different obligation from the MIT or Apache terms most web clients carry, and it should be read before you fork.
The release history shows a project shipping steadily. The listed versions are stoat-for-web-v0.16.3 on 2026-10-13, v0.16.2 on 2026-10-12 and v0.16.1 on 2026-10-01, and the root package.json version field matches at 0.16.3, so the tag and the manifest do not drift. The last push was on 2026-10-20, and the repository is not archived.
Release automation is in place through release-please, with a manifest and a config file plus a CHANGELOG.md, and renovate.json handles dependency updates. There is a GitHub Actions setup in .github/ and a .vscode/ directory with shared editor settings.
For contributors, the README points at the contribution guidelines hosted at developers.stoat.chat and at the project's own technical documentation at stoatchat.github.io/for-web. The roadmap is tracked on OpenProject, and the link still carries the older project name in its path, which is a reminder that the service and its tooling were renamed.
One maintenance note: the type of project that ships a client alongside a service changes when the service changes its API. The version numbers here are small, under one, and they move quickly, which is a signal that the client tracks an active backend rather than standing still.
Editorial conclusion
Stoat for Web is worth reading or self-hosting if you run a Stoat instance and want a web client you control, since the build produces a static directory and the backend address is decided by environment variables at runtime rather than compiled in. It is the wrong tool if you want a general purpose chat client for other services, because the whole surface is bound to the Stoat API through the stoat.js package. Before you deploy it, verify three things yourself: that the VITE_API_URL, VITE_HOST and the other variables are injected correctly at container start, since the image ships placeholders rather than real values, that the server routes all ten paths the client needs, and that the AGPL-3.0 licence fits your deployment, because a modified client served over a network carries the copyleft obligation with it.
Frequently asked questions
How do I run the Stoat for Web client locally?
Install Git and mise-en-place, clone the repository with submodules using git clone --recursive https://github.com/stoatchat/for-web client, then run mise install:frozen, mise build:deps, copy packages/client/.env.example to packages/client/.env and start mise dev. The dev server then serves the app at local.revolt.chat:5173.
How do I point Stoat for Web at the hosted backend instead of a local one?
Open packages/client/.env and comment out the local URL variables, for example #VITE_API_URL=http://localhost:14702. When those variables are not set the client automatically falls back to the official backend, as described in the env.ts file under packages/client/components/common/lib.
What routes must my web server support for Stoat for Web?
Ten paths are needed: /login, /pwa, /dev, /discover, /settings, /invite, /bot, /friends, /server and /channel. They are defined in packages/client/src/index.tsx, and since the app is a single page application the host has to serve the client entry document for each of them.
How do I build and deploy the Stoat for Web app?
Run mise install:frozen, mise build:deps and mise build, or mise build:prod for the Stoat production build, then deploy the packages/client/dist directory. The Docker build also uses NODE_OPTIONS with --max-old-space-size=4096 for the Vite step because the plugin-legacy polyfill detection needs more than the default heap.
What licence does Stoat for Web use?
AGPL-3.0, with the LICENSE file at the repository root. Since it is the network copyleft variant, serving a modified client to users over a network carries the obligation to offer the corresponding source of those modifications.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/stoatchat-for-web)Was this useful?