Bringing a mobile-first community platform to the web without forking its behaviour.
Next.js web client for Whistler: 52 routes across feed, communities, moments, messages and settings, sharing the mobile app’s API, tokens and error handling.
- Sole engineer
- Sep 2026 — Present · in active development
- Sole engineer; the backend API is shared with the mobile app.
- Web · Next.js App Router
- Next.js 16
- React 19
- TypeScript
- Tailwind CSS
- TanStack Query
- React Hook Form
- Zod
- ONNX Runtime Web
- Vitest
- Playwright
- Docker
- GitHub Actions
- Cloudflare Workers
Context
Whistler started as a mobile app. The web client brings the same communities, feed, moments, messages and settings to the browser, against the same backend.
It is in active development: the core shell, auth, feed, communities, moments, messages and notifications are built; social login, real-time message delivery and some settings pages are still on the roadmap.
My responsibility
I owned
- The whole web client — every human-authored commit in the repository is mine
- Architecture: route groups, server-side session guards, the API layer and the theme system
- Feature modules: core shell, communities, moments, news feed, messages and notifications
- The in-browser post classifier: the model export script, a BERT tokenizer in TypeScript, on-device caching and the category matching
- Testing: 23 Vitest unit test files and a Playwright suite that runs against a local mock API
- CI/CD: GitHub Actions, the Docker image, GitHub Container Registry publishing, Cloudflare Workers deploys and Dependabot
I collaborated on
- The backend API, shared with the mobile app and maintained separately
- Figma designs for each screen
The challenge
Two clients for one product drift apart quietly: a 401 produces a different message, a colour is slightly off, a validation rule differs. The web client had to behave like the mobile app without sharing its code.
It also had to be a good web citizen — a fast, indexable public surface — while everything behind sign-in stays private and protected on the server, not just hidden in the UI.
Constraints
- Same backend, different runtime
- The browser calls the external API directly (no proxy in this phase), so auth has to work cross-origin.
- Public pages must stay static
- The landing page is the SEO surface. Anything that forces dynamic rendering — like reading cookies in the root layout — costs performance on every page.
- Parity with mobile
- Theme tokens, API error messages and request helpers need to match the mobile app exactly.
Architecture
- Landing page (static, indexed)
- sitemap.ts
- Login, register, OTP, password reset, onboarding
- One shared AuthHeader
- noindex
- Server-side requireSession() guard
- One SidebarNav + TopHeader
- noindex
- features/*/hooks — TanStack Query wrappers
- Post classifier (ONNX Runtime Web, in the browser)
- lib/api client mirroring mobile’s interceptors and error messages
- Whistler REST API (shared with mobile)
- Route groups split the app into a public surface, logged-out forms and the authenticated app — each with exactly one header implementation.
- Protected pages are checked on the server with requireSession(), so access control is real, not just hidden UI.
- Theme tokens are ported one-to-one from the mobile app and rendered as CSS custom properties, so both clients share names and values.
- The post classifier runs entirely in the browser; post text never leaves the device to be categorised.
Key decisions
01A JS-readable session cookie, deliberately
- The browser calls the API directly and has to attach a bearer token, while server components also need to see the session.
- httpOnly cookie behind a backend-for-frontend proxy
- localStorage only
- Secure, SameSite=Lax, non-httpOnly cookie
- An httpOnly cookie can’t be read by the code that sets the Authorization header unless every request goes through a proxy — a whole new service. localStorage is invisible to server components, so routes couldn’t be protected on the server.
- Store the JWT in a Secure, SameSite=Lax cookie readable by both client code and server components.
- It enables real server-side route protection, and it is no weaker than the mobile app’s plain token storage. Moving to a proxy is a documented later step.
- Protected routes are enforced on the server from the first request.
02Keep the public page static instead of reading the theme cookie
- A saved dark-mode preference should apply on first paint.
- Read the cookie in the root layout
- Use CSS prefers-color-scheme and correct before paint on the client
- Reading cookies in the root layout makes the entire app — including the landing page — dynamically rendered. The CSS approach leaves a brief flash only for users whose saved choice contradicts their OS.
- Use prefers-color-scheme for the first paint and apply a stored preference in a layout effect.
- The project’s priority order puts SEO and performance ahead of a rare visual edge case.
- The landing page stays statically rendered; most users get the right theme with zero JavaScript.
03Match posts to category descriptions instead of porting the mobile model
- The moment composer should suggest a category as the user types, like the mobile app. But the mobile app’s fastText model is trained on a fixed list of 30 categories, and the web client gets its category list from the API.
- Port the mobile fastText model
- Send the text to a hosted LLM
- Compare sentence embeddings with short descriptions of each category
- The fastText model is small, but it can only predict the categories it was trained on, and adding one means retraining. An LLM adds cost, latency and a third party for every post. Embeddings work with any category list but need a much larger download: a 23 MB quantised all-MiniLM-L6-v2 model and a 14 MB WebAssembly runtime.
- Run all-MiniLM-L6-v2 in the browser with ONNX Runtime Web and score each post against a few everyday descriptions per category. A category the classifier doesn’t know is matched on its own title.
- A new or renamed category works without retraining anything, and the text stays on the device. The download only happens when someone starts writing, and only once.
- Suggestions work for any category list the API returns. Accuracy hasn’t been measured yet.
04End-to-end tests against a mock backend
- E2E tests that need a real account and network are slow, flaky and leak test data into production.
- Test against staging
- Skip E2E
- Run Playwright against a local mock API
- A mock can drift from the real API. Staging is realistic but slow and shared.
- Playwright builds the app against e2e/mock-api.mjs; CI uploads the HTML report and traces.
- Tests need no credentials or network, so they run on every pull request.
- Login validation, guest mode, notifications, moments, messages, polls and comments are covered on every PR.
Implementation
- Routes
- 52 pages: feed, discover, communities (members, insights, polls, leader hub, admin assignment), moments, messages, notifications, profile, settings and onboarding.
- Forms
- React Hook Form with Zod across nine forms: sign-in, registration, OTP, password reset and change, onboarding, profile, and support.
- Post classifier
- A script exports the model, vocabulary and runtime to content-hashed URLs, which the browser keeps in Cache Storage, so each version downloads once. A TypeScript port of the BERT tokenizer is tested against Transformers.js output. A check takes about a millisecond, 480 ms after typing stops. It only suggests a category when the post scores at least 0.2 against it and beats the runner-up by 0.08, and it never replaces a category the user picked.
- Messages
- Currently a polling fallback. Moving to the mobile app’s socket layer is the next step.
- Tooling
- ESLint with warnings as errors, Prettier, the React Compiler, and pre-commit hooks that run CI’s own checks.
Production
- CI
- Format, lint, types, unit tests and an npm audit of production dependencies; Playwright e2e; a Docker build that is run and probed for /api/health, /login and a non-root user. A single “CI passed” check gates main.
- Images
- On main, a standalone Next.js image (Node 22, Alpine, non-root) is pushed to GitHub Container Registry with a signed build-provenance attestation. Deploys are by commit SHA, so rollback is redeploying the previous tag.
- Environments
- Development and staging deploy to Cloudflare Workers through OpenNext.
- Maintenance
- Dependabot opens weekly PRs for npm, GitHub Actions and the Docker base image. The health check never calls the backend, so an API outage doesn’t restart healthy web containers.
Screens

MobileHome· 1 / 3
Mobile: a bottom tab bar and a horizontal community carousel, matching the native app — running in Safari. Email address, user photos and URL redacted.
Outcome
The web client covers the core of the product and ships through a pipeline that checks quality, behaviour, security advisories and the container itself before anything is published.
- Routes built
- 52
- Pages under the App Router at the time of writing.
- Unit test files
- 23
- Plus a Playwright e2e suite.
- Classifier accuracy
- —
- Impact metric unavailable — not evaluated yet. The thresholds were set by hand, not tuned on labelled posts.
- Active users
- —
- Impact metric unavailable — not yet launched publicly.
Lessons
Parity between clients is a design decision, not an accident. Porting the mobile app’s tokens, interceptors and error messages one-to-one removed a whole class of “why does web say something different?” bugs before they existed.
Writing down deliberate deviations — the cookie choice, the theme trade-off — next to the code saves the next reviewer from “fixing” them.
Next iteration
- Evaluate the classifier on the mobile app’s labelled sentences, split by sentence, so the two approaches can be compared.
- Replace message polling with the socket layer the mobile app already uses.
- Add Google and Apple sign-in once the web OAuth clients are set up.
- Test a Content-Security-Policy against Google sign-in, Giphy and the socket server.
- Cover sign-up, OTP and a successful login in the e2e suite.

