A shared sticker board for ideas, retrospectives, and brainstorming sessions. Open a link, drop a sticker, react to others, drag it where it belongs — everyone in the room sees it live.
🎯 What this is: Think Miro / FigJam / Mural, but tiny, focused on sticky notes only, and free to self-host. Built to demonstrate that real-time collaboration doesn't require a custom backend.
🧑💻 Who it's for: Teams running async retros, workshops, or quick group brainstorms. Anyone who wants a "drop your idea here, see what others think" surface that works the moment you share a link.
Live demo: https://stickerboard-nu.vercel.app/
- Features
- Tech stack and why
- Architecture at a glance
- Quick start (local dev)
- Walkthrough
- Database schema
- How key pieces work
- Project structure
- Scripts
- Tests
- Deploying to Vercel
- Open-source hygiene (don't leak secrets)
- FAQ
- Troubleshooting
- Contributing
- License
- 🔗 Open boards — share a link, anyone with it can join. No signup, no accounts to manage.
- 🟨 Stickers in 6 default categories — Product Ideas, Improvements, Customer Pain Points, Technical Debt, Questions, Risks. Admins can rename, recolor, reorder, or add new ones.
- ⚡ Real-time collaboration — stickers, reactions, presence avatars, and the lock state sync across every browser within a few hundred milliseconds. No refresh needed.
- 🎯 Drag & drop — reorder stickers within a column, move them between columns. Touch + keyboard supported.
- 👍 ❤️ 🚀 ❓ Reactions — four types per sticker, toggle on/off, counts update live.
- 🛡 Roles — board creator is admin; admins can moderate any sticker, manage categories and members, lock the board.
- 🔒 Lockable — admin freezes the board after a session. Visitors keep read access; everyone sees a "Locked" banner instantly.
- 📥 CSV export — one click, opens cleanly in Excel/Sheets, includes reaction counts and Unicode (Greek, CJK, Arabic, emoji all round-trip).
- 📱 Responsive — desktop grid, mobile pill-tab layout.
- 🔔 Toasts + error boundary — failures surface to the user; a top-level boundary catches render crashes.
| Layer | Choice | Why this one |
|---|---|---|
| Frontend | Vite + React 19 + TypeScript | Fast dev loop, mature ecosystem, types catch issues before users do. |
| Styling | Tailwind v4 with CSS @theme tokens |
Tokens live next to the styles that use them; no separate config file. Tokens match the Pencil mocks exactly. |
| Routing | react-router-dom | Tiny, no router-as-framework lock-in. Three routes total (/, /b/:slug, /b/:slug/admin). |
| Drag & drop | @dnd-kit | Accessible (keyboard support, ARIA), good touch handling, doesn't fight React 18+ concurrent rendering. |
| Backend / DB | Supabase (Postgres + Realtime + Auth + RLS) | A managed Postgres with realtime change feeds and row-level security baked in. Removes 90% of the "build a backend" work. |
| Auth | Supabase Anonymous Sign-Ins | An open-link board needs identity (so RLS can say "you can edit your own stickers") but shouldn't force signup. Anonymous sessions give every visitor a stable user ID without an email. |
| Export | papaparse | Tiny, handles quoting / Unicode / BOM correctly. Runs entirely in the browser; no server endpoint. |
| Testing | Vitest + Testing Library + jsdom | Vitest reuses the Vite pipeline, so tests run in ~1 s. Testing Library is the conventional React component-test stack; jsdom gives us a DOM without booting a browser. |
| Hosting | Vercel for the frontend, Supabase for the backend | Both have generous free tiers; both auto-deploy from a Git push. |
Design source of truth: stickerboard.pen (Pencil). Every screen and component in the React app traces back to a frame in this file — colors, radii, shadow values are pulled directly from the Pencil tokens.
┌─────────────────────┐ ┌──────────────────────────┐
│ Browser A (admin) │ │ Browser B (visitor) │
│ ───────────────── │ │ ────────────────────── │
│ React + Vite │ │ React + Vite │
│ @dnd-kit, Tailwind │ │ @dnd-kit, Tailwind │
└──────────┬──────────┘ └────────────┬─────────────┘
│ │
│ HTTPS (PostgREST) │ HTTPS (PostgREST)
│ WebSocket (Realtime) │ WebSocket (Realtime)
│ │
└────────────────────┬─────────────────────────┘
│
┌──────▼─────────────────────────┐
│ Supabase (managed) │
│ ┌──────────────────────────┐ │
│ │ Postgres (RLS enforced) │ │
│ │ boards, categories, │ │
│ │ stickers, reactions, │ │
│ │ memberships │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Realtime broadcaster │ │
│ │ tails Postgres WAL, │ │
│ │ pushes change events │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Auth (anonymous + JWT) │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
The key idea: when Browser A inserts a sticker, Postgres writes the row, Supabase Realtime sees the change in the WAL, broadcasts an INSERT event over the WebSocket, and Browser B's React state updates — usually under 300 ms. No server for you to maintain.
What it costs: Supabase and Vercel free tiers cover everyday use comfortably (sub-MAU workshops, small teams, demos). Expect to upgrade only if you sustain hundreds of daily concurrent connections.
| Tool | Why | Install |
|---|---|---|
| Node.js ≥ 20 | Runs Vite | https://nodejs.org/ or brew install node |
| Supabase project | Backend | Free at https://supabase.com/dashboard/projects |
| Supabase CLI | Pushes migrations | brew install supabase/tap/supabase |
git clone https://github.com/ChryssaAliferi/stickerboard.git
cd stickerboard
npm installIn https://supabase.com/dashboard:
- Authentication → Providers → Anonymous Sign-Ins: toggle on. The app uses anonymous sessions for the open-link join flow; if this is off the app can't sign anyone in.
- Settings → API:
- Copy the project URL (looks like
https://xxxxxxxxxxxxxxxxxxxx.supabase.co). - Copy the publishable key (starts with
sb_publishable_). This is the new public client key — safe to expose in the bundle. Don't copy the secret key.
- Copy the project URL (looks like
cp .env.example .env.localOpen .env.local and fill in:
VITE_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxx.env.local is gitignored. Real secrets must never be committed — see Open-source hygiene.
The whole schema lives in supabase/migrations/ as ordered SQL files. Push them to your project:
supabase login # opens a browser, do this once
supabase link --project-ref YOUR-PROJECT-REF # connects this folder
supabase db push # runs every migration in orderWhere to find your project ref: it's the 20-character subdomain in your Supabase project URL — https://<PROJECT-REF>.supabase.co. You can also see it in the dashboard at Settings → General → Reference ID.
After this, you'll have:
- 5 tables:
boards,categories,stickers,reactions,memberships - RLS policies covering open-link reads, author-write, admin-all, and the lock model
- Triggers that auto-create the admin membership for a board's creator, auto-place new stickers at the end of their column, denormalize
board_idonto reactions, and block self-promotion - The data tables published on the realtime channel with
REPLICA IDENTITY FULLso DELETE events make it through filters
npm run devA typical end-to-end use:
- Lands on
/. Names the board ("Q2 Retro") and themself ("Maya"), clicks Create board. - App creates the board row (
created_by= anon UID), a Postgres trigger auto-creates theadminmembership. - App seeds the 6 default categories with their pastel colors.
- App redirects to
/b/q2-retro-7r2k4. Maya sees an empty board and an "Admin panel" link in the top bar. - She copies the URL and shares it in Slack.
- Diego clicks the link. App signs him in anonymously, prompts for a display name, registers him as a
user. - He sees Maya's avatar in the presence stack, plus a "Live · 2 online" indicator. He drops a few stickers; they appear instantly on Maya's screen.
- He reacts 👍 to one of Maya's stickers; the count flips from 0 → 1 on her screen.
- Session ends. Maya goes to Admin panel → Board settings, flips Lock board.
- Diego's screen instantly shows the dark "Locked" banner; his Add-sticker / drag / Edit buttons disappear.
- Maya goes to Admin panel → Export, clicks Download CSV, opens the file in Sheets and shares it with the team.
All tables live in the public schema with RLS enabled.
boards categories stickers
───────────── ───────────── ─────────────
id uuid PK id uuid PK id uuid PK
slug text UNIQUE board_id uuid FK ──┐ board_id uuid FK ──┐
name text name text │ category_id uuid FK ──┤
is_locked bool color text │ title text │
created_by uuid (auth) position int │ description text │
created_at timestamptz created_at ts │ color text │
│ author_id uuid │
│ author_name text │
│ position int │
│ created_at ts │
│ updated_at ts │
▼ │
memberships reactions │
───────────── ───────────── │
board_id uuid FK ───┐ id uuid PK │
user_id uuid FK │ board_id uuid FK (denorm) ───────┘
role enum │ sticker_id uuid FK
display_name text │ user_id uuid FK
created_at ts │ type enum (like|love|impact|clarify)
PRIMARY KEY (board, u) │ created_at ts
│ UNIQUE (sticker, user, type)
▼
(admin | user)
Key invariants enforced at the DB level:
- Author can't fake another author:
stickers.author_id = auth.uid()is checked on insert (RLS). - One reaction-per-type-per-user: enforced by the unique constraint, which the realtime client treats as a safe toggle.
- Self-promote is blocked: a trigger refuses any membership
rolechange unless the caller is already an admin. - Locked boards reject non-admin writes: helper
is_board_unlocked(board_id)is referenced by every sticker/reaction policy.
When a visitor hits /b/:slug:
useAuthhook signs them in anonymously if no session exists. They get a stable Supabaseuser.id(a UUID).Boardroute fetches the board by slug. If not found → 404.- Looks up an existing membership; if none, calls
joinBoardAsUser(insert intomembershipswith roleuser). The "self-join as user" RLS policy permits this only for the calling user with roleuser. - If
localStoragehas no display name, shows theDisplayNameDialog. On submit,localStorageis set and the membership row'sdisplay_nameis updated.
The board creator skips most of this: a Postgres trigger (handle_new_board) auto-inserts an admin membership when their board row is inserted. The client then patches display_name on that row.
The useRealtime* hooks subscribe to Postgres change events filtered by board:
useRealtimeBoard(boardId)— watches the single board row for the lock toggle.useRealtimeStickers(boardId)— INSERT / UPDATE / DELETE onstickersfiltered byboard_id.useRealtimeReactions(boardId)— INSERT / DELETE onreactionsfiltered by the denormalizedboard_idcolumn (a trigger fills it in fromsticker_id).usePresence(boardId, userId, displayName)— Supabase Presence channel, broadcasts{ user_id, display_name, joined_at }.
Two non-obvious bits:
REPLICA IDENTITY FULLis required on each table or DELETE events would only carry the primary key in the WAL — the realtime filter onboard_idwould never match and subscribers would never see deletes.- Each handler is idempotent so an INSERT race (event arrives before initial
SELECTreturns) doesn't double-add.
| Table | Read | Insert | Update | Delete |
|---|---|---|---|---|
boards |
anyone | only authenticated, must be created_by = auth.uid() |
only board admins | only board admins |
categories |
anyone | only board admins | only board admins | only board admins |
stickers |
anyone | author = auth.uid() AND (board unlocked OR caller is admin) |
own + unlocked, or admin | own + unlocked, or admin |
reactions |
anyone | own + unlocked, or admin | (no update policy by design) | own, or admin |
memberships |
anyone | self-join as user, OR admin |
self-rename, OR admin (role-change blocked by trigger) | only admins |
@dnd-kit wraps the column grid in a DndContext. Each CategoryColumn is a SortableContext listing its sticker IDs; each SortableSticker is a useSortable item.
On drop:
- Compute the new order for the source column (without the moved sticker) and the destination column (with the moved sticker inserted at the drop index).
- Optimistically update local state so the UI doesn't flicker.
- Persist via parallel
UPDATEs — set newpositionfor every affected row, pluscategory_idon the moved sticker if it crossed columns. - The same updates come back via realtime, harmlessly overwriting our optimistic state with the same values.
If the persist fails (e.g. RLS blocks because the user isn't the author), we surface a toast and trust the next realtime update to reconcile.
.
├── README.md ← you are here
├── stickerboard.pen ← Pencil designs (5 screens)
├── LICENSE ← MIT
├── vercel.json ← SPA rewrite for client routing
├── vitest.config.ts ← jsdom + Testing Library setup
├── .env.example ← env-var contract
│
├── src/
│ ├── App.tsx ← router + ToastProvider + ErrorBoundary
│ ├── main.tsx ← entry; wraps in BrowserRouter
│ ├── index.css ← Tailwind v4 @theme tokens
│ ├── vite-env.d.ts ← typed import.meta.env
│ │
│ ├── components/ ← presentational + small wrappers
│ │ ├── AppShell.tsx
│ │ ├── StickerCard.tsx (+ .test.tsx)
│ │ ├── SortableSticker.tsx
│ │ ├── CategoryColumn.tsx
│ │ ├── StickerDialog.tsx
│ │ ├── ReactionBar.tsx (+ .test.tsx)
│ │ ├── PresenceStack.tsx
│ │ ├── MobileCategoryTabs.tsx
│ │ ├── DisplayNameDialog.tsx (+ .test.tsx)
│ │ ├── Skeleton.tsx
│ │ ├── Toast.tsx
│ │ ├── ToastContext.ts
│ │ ├── ErrorBoundary.tsx
│ │ ├── AdminCategoriesPanel.tsx
│ │ ├── AdminMembersPanel.tsx
│ │ ├── AdminSettingsPanel.tsx
│ │ └── AdminExportPanel.tsx
│ │
│ ├── hooks/ ← reusable hooks
│ │ ├── useAuth.ts
│ │ ├── useRealtimeBoard.ts
│ │ ├── useRealtimeStickers.ts
│ │ ├── useRealtimeReactions.ts
│ │ ├── usePresence.ts
│ │ ├── useToast.ts
│ │ └── useMediaQuery.ts
│ │
│ ├── lib/ ← API + helpers, no React
│ │ ├── supabase.ts ← typed client
│ │ ├── boards.ts ← create/get/lock/rename
│ │ ├── categories.ts ← list/create/update/delete/reorder
│ │ ├── stickers.ts ← list/create/update/delete/move
│ │ ├── reactions.ts ← list/toggle
│ │ ├── members.ts ← list/setRole/remove
│ │ ├── export.ts ← CSV generation via papaparse (+ .test.ts)
│ │ ├── download.ts ← Blob → file download
│ │ ├── slug.ts ← name → URL slug (+ .test.ts)
│ │ ├── time.ts ← relative-time formatter (+ .test.ts)
│ │ └── storage.ts ← localStorage display-name
│ │
│ ├── routes/
│ │ ├── Landing.tsx ← /
│ │ ├── Board.tsx ← /b/:slug
│ │ └── Admin.tsx ← /b/:slug/admin
│ │
│ └── test/
│ └── setup.ts ← jest-dom matchers + cleanup() per test
│
├── supabase/
│ ├── config.toml ← supabase init output
│ └── migrations/ ← ordered SQL — DON'T EDIT, only add new
│ ├── 0001_init.sql schema, indexes, enums
│ ├── 0002_rls.sql RLS policies + helpers
│ ├── 0003_creator_admin_trigger.sql
│ ├── 0004_realtime.sql add tables to publication
│ ├── 0005_replica_identity.sql
│ ├── 0006_reactions_board_id.sql
│ ├── 0007_sticker_position_trigger.sql
│ └── 0008_memberships_display_name.sql
│
└── scripts/
└── db-dump.sh ← schema + data backup
| Command | What it does |
|---|---|
npm run dev |
Vite dev server with HMR, on port 5173 |
npm run build |
TS check + production build to dist/ |
npm run preview |
Serve the production build locally (sanity check) |
npm run lint |
ESLint over the whole src/ tree |
npm run format |
Prettier write |
npm run format:check |
Prettier check (CI-friendly) |
npm test |
Vitest in watch mode (re-runs on save) |
npm run test:run |
Vitest single run, exits non-zero on failure (CI-friendly) |
./scripts/db-dump.sh |
Snapshot schema + data of the linked Supabase project to backups/ |
Vitest + Testing Library, configured in vitest.config.ts to use a jsdom environment. Tests live next to the source they cover (foo.ts → foo.test.ts).
Currently covered (~42 tests, runs in ~1 s):
- Pure helpers —
slug.ts(URL-friendly slugs),time.ts(relative timestamps), andexport.ts'sbuildExportRows+rowsToCsv(CSV row construction, BOM, escaping, Unicode round-trip). - Components —
StickerCard,ReactionBar,DisplayNameDialog. Render output, accessibility queries, callback firing, prop-driven gating (e.g. Edit/Delete only whencanModerate, reaction buttons disabled when locked).
Not yet covered:
- Hooks that talk to Supabase (
useAuth,useRealtime*,usePresence) — these would need a Supabase client mock. - Integration tests that drive
Board.tsxend-to-end against a mocked backend.
Add tests for any new pure logic before opening a PR. Component tests are appreciated but not enforced.
The repo is set up to deploy on every push to main:
- Go to https://vercel.com/new.
- Click Import next to the GitHub repo.
- Framework preset: Vite (auto-detected).
- Root directory: leave at
./. - Build command and output directory: leave defaults (
npm run build→dist). - Environment Variables — add both, ticked for Production / Preview / Development:
VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEY
- Click Deploy. The first build takes ~30 seconds.
Vercel hands you a URL like https://stickerboard-<hash>.vercel.app. Visit it; create a board; share the URL with someone on a different network. They should be able to join, drop stickers, and you should see them in real time.
The committed vercel.json rewrites every path to index.html, so deep links like https://your-app.vercel.app/b/q2-retro resolve correctly when typed directly into the address bar (otherwise Vercel's static file server would 404).
Vercel → project → Settings → Domains → add domain, follow the DNS instructions. Free for any domain you own.
The app ships with a top-level ErrorBoundary that catches render crashes and shows a Reload screen. For production telemetry, drop in @sentry/react (or an alternative) and initialize it in main.tsx. Not bundled by default so the JS payload stays small.
This project was built to be open from day one. Some rules baked into the repo:
.env.localand any.env.*.local,*.pem,*.key,*.p12,secrets/,*.local.jsonare gitignored.- The publishable Supabase key is designed to be public — it ships in the JS bundle and only allows the operations RLS permits anyway. But still, treat all keys as one-way doors: once committed, history is permanent.
- Server-only secrets (
SUPABASE_SECRET_KEY,DATABASE_URL) must never appear with aVITE_prefix. Vite would inline them into the public bundle. - If you're forking and committing somewhere else, the
LICENSErequires you keep the copyright notice.
Q: Do users need accounts to use a board? No. Anonymous Supabase sessions give every visitor a stable user ID without an email. Authorship and RLS work normally.
Q: Is the publishable key really safe to put in the client? Yes. It has no admin powers; every action it can take is gated by RLS. Compare it to a database "API key for guests" — the only thing it lets you do is what RLS allows.
Q: Can I use a different name / domain / branding?
Sure. The MIT license permits it. index.html <title> and src/components/AppShell.tsx are the obvious places to start; the design tokens in src/index.css are the rest.
Q: How do I change my own display name?
There's no rename UI yet — clear the browser's localStorage for the site (DevTools → Application → Local Storage → delete the stickerboard.displayName key). On your next visit the join dialog reappears and asks for a new name. (A proper rename action is a small post-v1 addition.)
Q: How do I delete a board?
There's no UI for it yet. Run delete from boards where id = '...' against your Supabase database — the foreign keys cascade so categories, stickers, reactions, and memberships go with it.
Q: How do I support languages other than English? Out of the box, the UI is English. The Inter + Geist Mono fonts loaded from Google Fonts cover Greek, Cyrillic, and most Latin extensions automatically. Sticker content already round-trips Unicode (Greek, CJK, Arabic, emoji) through the database and the CSV export. UI text localization (i18n) is post-v1 work.
Q: How is "open to anyone with the link" actually secured? The slug is a generated random suffix. RLS allows reads for anyone but only authors can write to their own stickers and only admins can do anything else. If a slug leaks, the worst case is people add stickers — they can't escalate to admin, can't delete others' content, can't see anything outside that one board.
Anonymous sign-in fails with Anonymous sign-ins are disabled
Toggle it on at Authentication → Providers → Anonymous Sign-Ins in the Supabase dashboard.
Realtime never delivers DELETE events
The table needs REPLICA IDENTITY FULL. Run migration 0005_replica_identity.sql (already applied if you supabase db push'd the whole folder).
Migrations fail with permission errors
You're probably not linked to the project. Run supabase link --project-ref YOUR-PROJECT-REF again.
Vercel build fails: "Cannot find module …"
Make sure npm install completed locally first, and that package-lock.json is committed. Vercel reads from the lockfile.
/b/something returns a Vercel 404 on the deployed site
The SPA rewrite in vercel.json should fix this. If it doesn't, check that vercel.json is at the repo root and was included in the deployed commit.
Locked board still lets a visitor edit on screen
RLS will block the actual write, but the UI gate may be stale. Refresh; the is_locked value comes through realtime so the next event from the server will sync it.
Display name on the membership row doesn't match localStorage
The membership update happens after the dialog submits and may fail silently if there's a transient network issue. The next time the user submits the dialog (or a future "rename" UI is added), it'll catch up.
PRs welcome. Quick rules:
npm run lint && npm run format:check && npm run test:runshould be clean.- Database changes go in a new ordered migration file under
supabase/migrations/and ship viasupabase db push. Never edit existing migration files — they may have been applied to other forks/installs already. - UI changes should match the tokens in
src/index.css(which mirrorstickerboard.pen). If a UI change needs a new color or radius, update the .pen file too so the design and code stay in sync.
MIT — see LICENSE. Free for personal, commercial, or educational use; just keep the copyright notice.
