The plan was three of us.
Three grapplers, one open-mat schedule, a recurring problem: who's actually showing up tonight? The duct-taped solution we'd been running — a WhatsApp group, a coach's memory, and a shared spreadsheet that nobody updated — worked the same way duct tape works on a leaking pipe. Right up until it doesn't.
So I sat down to build the smallest possible thing that would tell us, at a glance, who was on the mats this evening. Just RSVPs. Three names. One subdomain.
Thirty-seven days later, the repo has 436 commits, the worker has 26,695 lines of JavaScript in one file, and it serves a 141-route platform that does training logs, sparring challenges, a cross-club throwdown board, an editable platform admin, four tone systems for the copy, a curated 115-technique library, and a heritage-styled magazine landing page that I did not, at any point, sit down and plan to build.
It got away from me. This is the article about how.
The shape of the thing
It's a multi-tenant Cloudflare Workers platform. Each grappling club gets its own subdomain — yourgym.grapplers.club — and behind it, its own member list, its own training data, its own schedule, its own private home on the internet. Members RSVP, log every roll, build heatmap streaks, issue sparring challenges, and share techniques. None of it crosses the club line without explicit opt-in.
The whole product fits inside three Cloudflare primitives:
- Cloudflare Workers — one
worker.jsfile, module syntax, that handles every request to every subdomain. Reads theHostheader, extracts the slug, loads the group from KV, dispatches to a route handler. 26k lines and counting. - Cloudflare KV — two namespaces.
GRAPPLERS_SYSTEMfor platform-wide things (the group registry, auth, OTPs).GRAPPLERS_DATAfor everything a club owns, every key prefixed with itsgroupId. - Cloudflare R2 — one bucket. Holds member avatars, club logos, and the platform logo.
That's the whole stack. No database in the relational sense. No Redis. No queue.
What's not in the stack is the more interesting part:
- No npm, no Node, no Wrangler. The deploy is a Python script (
deploy.py) that POSTs the file to Cloudflare's REST API. Setup is another Python script. Both stdlib-only. No external dependencies. If you can clone the repo and runpython3, you can deploy this thing. - No bundler.
worker.jsis one file because Cloudflare Workers accepts one file. So that's what we ship. - No framework, frontend or back. Vanilla JS and plain CSS. The "framework" is a
layout()helper function that wraps a string of HTML in the shell. The "components" are template-literal helpers. There is no virtual DOM, there is no hydration, there is no client-side router. Pages re-render. Forms POST. Browsers, blessed be their names, scroll back to the top.
The reason this works is the project doesn't need what a framework provides. Grapplers club is mostly forms and tables and a heatmap. The dynamic surface area is small — one canvas (the excuse roulette wheel), one heatmap interaction (click-a-day to see sessions), a few modals, a service worker for offline. None of that requires React, and writing it without React saved us from the maintenance trail React drags behind it.
Heritage Chronicler
The visual design is called Heritage Chronicler. It's the only part of the project that took longer than building features. Warm parchment cream, brown serif headlines (Noto Serif), uppercase eyebrow labels in 0.22em letterspacing, extending horizontal rules on section headers, a 4px border radius on everything that needs a border radius. It looks like a turn-of-the-century training journal, and that's the point — grappling is a sport with lineage, and the UI should know it.
There are five themes a club can pick from (darkbelt, midnight, ocean, chalk, tatami) but Heritage is the default direction.
There are also four tones — boomer, competitor, coach, neutral. Every piece of microcopy in the app comes through a tone.{key} resolver. A coach-tone club sees "Who's training today?" and "Great — see you on the mat!" A competitor-tone club sees "Training Schedule" and "RSVP confirmed." A boomer-tone club sees "Who's actually showing up?" and "You're in. Try to actually show up." Same database, same code, four personalities.
Nothing in the app is hardcoded in English-the-language. It's all tone.something. 181 keys × 4 voices = 724 strings that all have to land in the right place. Most of the development scar tissue lives in the tone system.
Privacy is the design constraint, not a feature
I work in cyber security for a living. The day job involves looking at what other people built and wondering, out loud, what they were thinking. Building something that grapplers — including the grapplers I train with — would actually trust meant the security model had to be load-bearing from day one, not shipped in a Q3 update.
A short list of what that looks like in practice:
- Email-only OTP authentication. No passwords. Nothing to phish, nothing to reuse, nothing to leak. Every login mints a 6-digit code valid for 15 minutes with a 5-attempt cap.
- HMAC-signed session cookies, scoped to the club. A cookie from
clubA.grapplers.clubwon't validate againstclubB.grapplers.club. Cross-club data leaks happen at the protocol layer. - Rate limits keyed by hashed identifier. Raw IP addresses never sit in KV. We SHA-256-truncate them before they become part of a rate-limit key, because GDPR has opinions about persistent IP storage and so do I.
- No raw user input becomes a KV key. Member names are validated against a regex that rejects colons (the key separator), angle brackets, control characters, and null bytes — closing the entire class of KV key-injection bugs.
safeJson()for everything user-controlled inside<script>blocks. Escapes<and>so a member's bio can't break out of a JSON literal and into the document.- CSP with per-request nonces.
script-srcno longer carries'unsafe-inline'. Inline event handlers are forbidden everywhere — every interactive<script>tag carries a fresh base64 nonce. CSP violations get POSTed back to/csp-reportand land in the platform activity log. - SVG uploads are blocked. Avatars and logos accept JPEG/PNG/WebP only. SVG is a code-execution vehicle dressed as an image and we don't pretend otherwise.
- Email enumeration is closed at the door.
/join-requestalways returns "submitted" — never "already a member," never "cooldown active." Probers learn nothing.
Where it's honest about what's still soft: the HMAC secret signing every session cookie is a single env binding. If it leaks, every active session is forgeable until rotated, and rotating logs everyone out. I know. It's on the list.
Privacy at the data level: every group lives behind its own subdomain, in its own KV-key-prefixed namespace. Member email addresses don't appear in any user-facing HTML other than the member's own profile and an admin's own admin panel. The cross-club Throwdown board is opt-in per member, with three visibility tiers (nickname, first name, full name) and a radius slider — and the default is "most private" because that's what people who don't read the form actually want. The privacy page lives at the apex. The security.txt is reachable on every domain. The robots.txt on every group subdomain says Disallow: / so no club's private mat schedule ends up in a search index.
None of this is novel. All of it is undramatic. That's the point: security and privacy show up in this app as the absence of obvious mistakes, not as a marketing page.
What got cut
The thing I'm most proud of is the list of features we built halfway and then dropped:
- Real-time messaging via Server-Sent Events. Started building it. Realized Cloudflare Workers' wall-time limits terminate any held-open stream every ~30 seconds. Decided we were not going to invent a polling layer for what is, at best, a way to coordinate open mats. Killed it. Permanently. The unread badge on the messages icon updates on the next page load, and that is the entire live-update story.
- Message reactions. Built the schema. Deleted the schema. We are not Slack.
- Edit your own message. Same.
- Toast notifications. Depended on the live-update channel that doesn't exist. Same.
Every one of these is documented in the project's CLAUDE.md under a section literally titled "Explicitly NOT shipped (DROPPED, not coming later)." Because someone — me, in a moment of weakness, or a future contributor — was going to ask. The list is there to say no for the third time.
The Claude Code angle
I built this with Claude Code sitting next to me the whole time. The 436 commits across 37 days look insane in isolation; in practice it's because Claude reads the repo, edits the file, runs the deploy, and watches the result, while I tell it what to build next and push back when it tries to add a feature flag I don't want.
A single recent session shipped seven commits to production: a dark-launched /about and /articles pair, optional invite emails on the admin panel, a refreshed landing-page screenshot, a sticky left section nav for /admin at ≥1280px, a cleanup that dropped 226 lines of unused message fields and a retired boomers-migration script, and — the one I'm happiest about — consolidating per-day training keys (training:{memberId}:{date}) into yearly buckets (training:year:{memberId}:{YYYY}), cutting the read volume on the heatmap from O(members × days) to O(members × ~2 years).
That last one is roughly a 100× reduction in KV reads for a 30-member club. It's the kind of refactor that pays for itself the moment a club has more than ~50 active members and starts to feel slow. It's the kind of refactor I would have deferred forever without a pair-programmer who could write the migration script, run it on staging, verify the result, and ship it before lunch.
What it costs
The whole platform runs inside Cloudflare's free tier. KV reads at our current volume don't cost anything. Workers requests don't either. R2 stores tens of avatars and a few logos. The only line item is the domain registration and Resend, which costs nothing until you start sending real volume.
Three grapplers can have a private home on the internet for the cost of a domain. Thirty grapplers, same. Three thousand, probably the same.
When it breaks
Two truths to start: software breaks, and Cloudflare's edge runs more nines than I do. Most of the reliability story is "we delegate to the platform," but the parts the project actually owns want their own treatment.
What's visible when something goes wrong:
/healthzreturns a 200 with environment and timestamp, on every domain. No KV reads, no auth, suitable for any uptime monitor.- The platform activity log captures 19 event types — login success, login failure, OTP brute force, rate-limit hits, group creation, member removal, tier changes, avatar uploads, CSP violations — with a paginated dashboard at the admin panel. Filters by category and severity, with a 14-day daily-activity chart and a storage indicator, so anomalies are visible without grepping.
- CSP violation reports hit
/csp-reportand get deduplicated to one entry per five minutes per page. If a third-party script tries to load on a member's profile, we hear about it before they do. - Cloudflare's own dashboard surfaces invocation errors and exception traces at the platform layer.
What happens at the browser when the server is unreachable:
- A service worker caches the last successful HTML for every authenticated page. Re-visits get the cached version instantly while the SW revalidates in the background — flaky connections at the gym don't show a spinner.
- When even the cache fails, there's a baked-in offline fallback page. Heritage-styled, fully static, no external assets. "You're offline 🥋 — the mat is unreachable right now."
Where it is now
The repo has a public clubs directory, a per-club landing page for clubs that opt in, a join-by-code apex flow with a printable QR for the wall of the gym, a privacy page, a security.txt, a service worker, a 100/100 Lighthouse PWA score, two environments (prod and staging), and an enricher Python tool that hits the YouTube Data API to find videos for the platform technique library so every move ships with a thumbnail.
Three grapplers. One worker.js file. Twenty-six thousand lines.
I should probably have stopped at the WhatsApp group.