Ashley Buckingham
Notes / Projects / Dubai Hammers

Dubai Hammers

Published:
Intended audience:
Developers, fans

The Dubai Hammers are the official West Ham United supporters club in the UAE. They started in 2012, when a group of fans who couldn't get home for the play-off final got together to watch it in Dubai. They now meet every match day in their own corner of a pub in Barsha Heights.

I built their website, their mobile app and the small API that feeds them, on my own. The app has been on the App Store since July 2025, with Android in beta on Google Play.

Three parts and a hub

The system is three repos: about 14,500 lines of TypeScript in the app, 8,000 in the website and 2,600 of JavaScript in the API. None of them call each other. A hosted database and file store sit in the middle.

fig. 1: how the three parts connect

The key decision is that the API publishes rather than serves. After every refresh it writes plain JSON files (fixtures, the live match, the table, news) to public file storage behind a CDN, with a five-minute cache. The app fetches those like static files.

  • Match-day load doesn't reach my server. Every phone refreshing the score at once hits a CDN, not Express.
  • The app keeps working when the API is down. The last published files are still there.
  • No shared types package. Each repo defines the shapes it reads. For a solo project, three small copies have been less work than a package to publish and version.

Anything tied to a person, like signing in, RSVPs and match predictions, goes through the database's own auth and functions, protected by row-level security.

The data API

The API is deliberately tiny: Node, plain JavaScript modules, and only two dependencies, Express and the database client. Tests run on the built-in node --test.

It pulls from three outside sources: a public football data feed for fixtures, live scores, line-ups and match events; a feed for the league table; and the club's Instagram, through an RSS bridge, for news. Each source has a mapper that turns its shape into mine, with tests, because that's where upstream changes break things.

A scheduler that follows the fixture list

The interesting part is a long-running scheduler that watches the next fixture and polls harder as kickoff gets closer:

  • Quiet days: fixtures every few hours, news hourly, the table twice a day.
  • Before kickoff: fixtures every 15 minutes, and live data every couple of minutes so line-ups appear as soon as they're announced.
  • During the match: live data every 30 seconds until well after the final whistle.
  • Afterwards: the result stays on the live screen for a day, then it moves to the next fixture.
fig. 2: how often the scheduler checks for new data

It's a single long-running process rather than a cron job, because the timing depends on the data. It's built from the same Docker image as the HTTP API and runs as a second service.

Push notifications

Notifications go through Expo's push service. They can target one person, everyone, members or non-members, and they respect each person's notification settings. The send script runs as a dry run unless told otherwise, so a test never messages the whole club.

The app

  • Stack: Expo and React Native with TypeScript, expo-router for file-based routing, and NativeWind for Tailwind-style classes. Biome handles linting and formatting.
  • State: a React context for auth, and plain fetch with local state for everything else. The data is small static JSON, so a caching library hasn't been worth the weight. The live screen polls every 30 seconds.
  • Native modules: notifications, secure storage, video, image caching, haptics, blur and glass effects, an in-app browser and QR codes for the membership card.
  • Features: fixtures and the league table; a live match screen with events, line-ups, stats and a momentum view; a "Guess the Goal" prediction game with a leaderboard; RSVPs for matches and events; a members area with a QR membership card; news; offers; and a shop.

One config file per club

The club's name, colours, navigation, about text, social links and shop items live in one club.json, documented separately. Swapping that file rebrands the app, so the same code could serve another supporters club.

Shipping updates

Builds run in the cloud, with separate development, preview and production profiles, each on its own update channel. That gives the app two ways to stay current:

  1. Over-the-air updates. JavaScript-only changes are published as a new bundle and reach phones on next launch, with no App Store review. The runtime version uses Expo's fingerprint policy, so an update only goes to builds whose native code matches.
  2. A minimum-version check. On launch, the app fetches a small version file with a four-second timeout. If the installed build is below the minimum, it shows an update screen instead of running stale native code.
fig. 3: what the app checks on launch

Upgrade scars

The app has been through several major Expo upgrades. One of them produced a release build that showed a white screen on iOS, but only in release, never in development. Two things got me through it: a root error boundary so release crashes show an actual message, and building React Native from source instead of using the SDK's precompiled modules. I tested over-the-air updates the same way, with a visible marker on the About screen to prove which bundle a phone was running.

The website

  • Stack: Next.js with the App Router, React, TypeScript, Tailwind CSS and Headless UI.
  • Pages: about 20 public pages (home, about, membership, shop, sign-in, legal) plus an admin area for members, posts, notifications and the prediction game. Admin changes go through Server Actions.
  • Hosting: it started on a managed platform, moved to a Docker container on a virtual server behind a reverse proxy, and now runs as a standalone Next.js build in a multi-stage, non-root Docker image, next to the API.

Membership payments

Memberships are paid online through a UAE payment provider. The server creates a payment intent and records it. The provider then calls a webhook, which is verified with an HMAC signature and a constant-time comparison before anything is trusted. The status page polls until the payment settles. Then the account is created or renewed in the database and the member gets a sign-in link by email.

A shop without a cart

Merchandise is simpler on purpose. The catalogue is a typed file in the repo, and the payment links the club uses can't call back to a website, so there's no automated checkout to build. On the site, a buyer pays by link with a generated order reference; in the app, the order becomes a pre-filled WhatsApp message to the club. It's low-tech, but it matches how the club already works.

Looking back

  • Publish, don't serve. Writing JSON files to a CDN turned the busiest moment, a goal on match day, into a non-event for the backend.
  • Schedule around the domain. A scheduler that knows when kickoff is beats a fixed cron: fast when it matters, quiet the rest of the week.
  • Release builds are a different app. Test them early, and make crashes visible in them.
  • Over-the-air updates need a safety net. Fingerprinted runtime versions and a minimum-version check stop a new bundle from reaching a build it can't run on.
  • Pick boring tools when you're the whole team. Plain fetch, React context, two backend dependencies and Docker mean there's less to keep in my head when something breaks at kickoff.