A real-time collaborative kanban board. Multiple people open the same board and see every change — cards created, edited, dragged between lists, deleted — the instant it happens, along with live presence and cursors of everyone else in the room.
Built to demonstrate a full-stack system end to end: a typed React front end, a FastAPI back end, WebSocket realtime with Redis pub/sub for horizontal scaling, JWT auth, PostgreSQL persistence, a test suite, and one-command Docker startup.
Stack: React 18 · TypeScript · Vite · Tailwind · FastAPI · SQLAlchemy 2 · PostgreSQL · Redis · WebSockets · Docker Compose
- Boards, lists, and cards — create boards (each seeded with To do / In progress / Done), add lists and cards, edit card titles and descriptions, drag cards within and across lists.
- Realtime sync — every mutation is broadcast over a WebSocket to everyone viewing the board. No refresh, no polling.
- Presence — avatars of who is currently on the board, with a live/reconnecting status indicator.
- Live cursors — see other people's pointers move across the board in real time.
- Sharing — invite a teammate by email to give them access to a board.
- Auth — register / login with hashed passwords (bcrypt) and JWT bearer tokens.
┌──────────────┐ REST (JSON) ┌───────────────────────────┐
│ React SPA │ ────────────────────▶│ FastAPI │
│ (Vite + TS) │ │ auth · boards · cards │
│ │ ◀════ WebSocket ═════▶│ WebSocket room manager │
└──────────────┘ events + presence └────────────┬──────────────┘
│
┌────────────┴────────────┐
│ │
┌───────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │
│ (SQLAlchemy)│ │ pub/sub for │
│ persistence │ │ multi-instance│
└───────────┘ └──────────────┘
The interesting part is the realtime layer. A single backend process holds in-memory "rooms" (one per board) and broadcasts events to the sockets in each room. To scale beyond one process, every instance publishes its events to a shared Redis channel and subscribes to it, so a change made on instance A reaches clients connected to instance B. If Redis is unavailable the backend degrades gracefully to single-instance broadcasting — the app still works, it just doesn't fan out across replicas.
State is server-authoritative: the client sends an action over REST, the server persists it and broadcasts the resulting event, and the client updates its UI when that event arrives over the socket — including for the user who triggered it. One source of truth, no divergence between clients.
git clone https://github.com/wiktor-cl/collabboard.git
cd collabboard
docker compose up --buildThen open http://localhost:8080. Postgres, Redis, the API, and the web app all start together. To see realtime in action, open the same board in two browser windows (or invite a second account) and start moving cards.
Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Point DATABASE_URL at a running Postgres, or use SQLite for a quick spin:
export DATABASE_URL="sqlite:///./dev.db"
export JWT_SECRET="dev-secret"
uvicorn app.main:app --reloadAPI docs are served at http://localhost:8000/docs.
Frontend
cd frontend
npm install
npm run devThe Vite dev server (http://localhost:5173) proxies /api and /ws to the
backend on port 8000, so there is no CORS or config to wire up.
The backend has a pytest suite covering auth, access control, board membership, invites, and the card create / move / reorder / delete logic (including position compaction across columns).
cd backend
pip install -r requirements.txt
pytestTests run against in-memory SQLite, so no external services are required.
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/register |
Create account, returns a JWT |
| POST | /api/auth/login |
Log in, returns a JWT |
| GET | /api/auth/me |
Current user |
| GET | /api/boards |
Boards the user belongs to |
| POST | /api/boards |
Create a board (+ default lists) |
| GET | /api/boards/{id} |
Full board with lists + cards |
| POST | /api/boards/{id}/invite |
Grant a user access by email |
| POST | /api/boards/{id}/columns |
Add a list |
| POST | /api/boards/{id}/cards?column_id= |
Add a card |
| POST | /api/boards/{id}/cards/{cid}/move |
Move / reorder a card |
| WS | /ws/boards/{id}?token= |
Realtime board channel |
collabboard/
├── .github/workflows/ci.yml # pytest + frontend build on every push/PR
├── backend/
│ ├── railway.json # backend deploy config (Railway)
│ ├── app/
│ │ ├── main.py # app wiring + lifespan
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py # Pydantic I/O
│ │ ├── security.py # bcrypt + JWT
│ │ ├── deps.py # auth + board-access dependencies
│ │ ├── realtime.py # WebSocket room manager + Redis pub/sub
│ │ └── routers/ # auth, boards, columns, cards, ws
│ └── tests/ # pytest suite
├── frontend/
│ ├── vercel.json # frontend deploy config (Vercel)
│ └── src/
│ ├── api/client.ts # typed API client (configurable base URL)
│ ├── auth/ # auth context
│ ├── hooks/useBoardSocket.ts # realtime hook (presence, cursors, reconnect)
│ ├── pages/ # Login, Boards, Board
│ └── components/ # Column, Card, PresenceBar, CursorLayer
└── docker-compose.yml
- The schema is created with
create_allon startup to keep the demo a single command. A production deployment would use Alembic migrations. - Card positions are stored as integers and compacted on move/delete. For very high-churn boards a fractional-index scheme would avoid rewrites.
- Cursor positions are ephemeral — broadcast but never persisted.
The app deploys as a split: the FastAPI backend on Railway (with managed Postgres + Redis) and the React frontend on Vercel. The frontend reads the backend's URL from build-time env vars, so the two talk cross-origin over plain HTTPS/WSS — no proxy in the middle.
Backend → Railway
- New project → Deploy from repo. Set the service's Root Directory to
backend(it usesrailway.jsonand the Dockerfile). - Add the PostgreSQL and Redis plugins. Railway injects
DATABASE_URLandREDIS_URLautomatically. (TheDATABASE_URLarrives aspostgresql://…; the app rewrites it onto the psycopg v3 driver itself, so there is nothing to configure.) - Add service variables:
JWT_SECRET(a long random string) andCORS_ORIGINSset to your Vercel URL, e.g.https://collabboard.vercel.app. - Railway health-checks
/api/healthand gives you a public URL.
Frontend → Vercel
- Import the repo. Set Root Directory to
frontend(Vercel detects Vite viavercel.json). - Add environment variables pointing at the Railway backend:
VITE_API_BASE=https://<your-app>.up.railway.appVITE_WS_BASE=wss://<your-app>.up.railway.app
- Deploy. Update the backend's
CORS_ORIGINSto match the final Vercel domain.
Locally, leave those vars empty — the Vite dev proxy and the Docker/nginx setup both keep the front and back on one origin.
MIT — see LICENSE.