Skip to content
Suleiman Francis

Whistler Web · Whistler

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.

Role
Sole engineer
Timeline
Sep 2026 — Present · in active development
Team
Sole engineer; the backend API is shared with the mobile app.
Platform
Web · Next.js App Router
Stack
  • 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

  1. Public
    • Landing page (static, indexed)
    • sitemap.ts
  2. (auth)
    • Login, register, OTP, password reset, onboarding
    • One shared AuthHeader
    • noindex
  3. (app)
    • Server-side requireSession() guard
    • One SidebarNav + TopHeader
    • noindex
  4. Features
    • features/*/hooks — TanStack Query wrappers
    • Post classifier (ONNX Runtime Web, in the browser)
  5. API layer
    • lib/api client mirroring mobile’s interceptors and error messages
  6. Backend
    • Whistler REST API (shared with mobile)
Components never call endpoints directly; they go through a feature hook.
  • 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

Problem
The browser calls the API directly and has to attach a bearer token, while server components also need to see the session.
Options
  1. httpOnly cookie behind a backend-for-frontend proxy
  2. localStorage only
  3. Secure, SameSite=Lax, non-httpOnly cookie
Trade-offs
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.
Decision
Store the JWT in a Secure, SameSite=Lax cookie readable by both client code and server components.
Reason
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.
Result
Protected routes are enforced on the server from the first request.

02Keep the public page static instead of reading the theme cookie

Problem
A saved dark-mode preference should apply on first paint.
Options
  1. Read the cookie in the root layout
  2. Use CSS prefers-color-scheme and correct before paint on the client
Trade-offs
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.
Decision
Use prefers-color-scheme for the first paint and apply a stored preference in a layout effect.
Reason
The project’s priority order puts SEO and performance ahead of a rare visual edge case.
Result
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

Problem
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.
Options
  1. Port the mobile fastText model
  2. Send the text to a hosted LLM
  3. Compare sentence embeddings with short descriptions of each category
Trade-offs
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.
Decision
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.
Reason
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.
Result
Suggestions work for any category list the API returns. Accuracy hasn’t been measured yet.

04End-to-end tests against a mock backend

Problem
E2E tests that need a real account and network are slow, flaky and leak test data into production.
Options
  1. Test against staging
  2. Skip E2E
  3. Run Playwright against a local mock API
Trade-offs
A mock can drift from the real API. Staging is realistic but slow and shared.
Decision
Playwright builds the app against e2e/mock-api.mjs; CI uploads the HTML report and traces.
Reason
Tests need no credentials or network, so they run on every pull request.
Result
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

Whistler Web home at phone width: an “Email Not Verified” banner with a verify link, a greeting, community filters (All communities, Hot & Trending, For You, Public), a Hot & Trending Communities carousel, a bottom tab bar, and Safari’s address bar below. The email address, community photos and URL are replaced with grey blocks.

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
52Measured
Pages under the App Router at the time of writing.
Unit test files
23Measured
Plus a Playwright e2e suite.
Classifier accuracy
—Not measured
Impact metric unavailable — not evaluated yet. The thresholds were set by hand, not tuned on labelled posts.
Active users
—Not measured
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.