mobile
The check-in app is one web page and two wrappers

A gym wants a tablet by the door. A member walks up, holds their phone's QR code to it or taps in a PIN, and the screen goes green. Nobody at the front desk is involved — that is the entire point, because at 18:25 on a Tuesday the front desk has a queue.
We already had a kiosk. It had been in the product since last autumn: a staff member stands behind it, finds the person, confirms the check-in. The obvious move was to add a mode to it. I started a new app instead, and then that app grew two native wrappers over the summer. This is what each of those decisions actually bought.
Why not a mode on the kiosk
The two screens look similar and have almost nothing in common underneath.
The staff kiosk is operated by someone who works there. It can show an error code, because the person reading it knows what to do with one. It can leave a screen open on the last member, because the operator moves it on. It assumes a trained user and a supervised device.
A self-service tablet assumes neither. Everything it says is read by a stranger, in the language their gym picked, once, while somebody waits behind them. It has to reset itself, because nobody will. It has to be unusable as a web browser, because it is mounted on a wall in a public room. Those are not feature flags; they are a different app with a different set of things it is allowed to do.
The cost of two surfaces showed up a month later, in a place I did not
predict. Both apps send the same public_kiosk product header and share the
same session cookie, so when we added a source column to every check-in —
so a club can see whether an entry came from reception, the member's phone,
an instructor, or the door — the header could not tell the two kiosks apart.
It had to be passed explicitly by the routes, and the mapping table in
server/lib/checkin-source.ts now carries a comment explaining exactly why
PUBLIC_KIOSK is deliberately unmapped. Telling the two apart is the whole
reason the column exists.
The organisation comes from the hostname, and from nothing else
Each club gets {slug}.checkin.1club.ai. The proxy resolves the slug off the
host, sets it as a header, and every request downstream carries it. There is
no path fallback and no query parameter.
This is a small rule with a long shadow. A tablet cannot be pointed at the wrong club by a mistyped link, and no screen in the app has to ask "which organisation is this" — the answer arrived with the request. It is also the single reason the native wrapper needs a setup screen at all, which I will get to.
Identification is not eligibility
The first version did what the staff kiosk did: one call that took a QR code or a PIN and came back with a check-in. Three weeks in I split it, and that split is the one I would keep in any similar app.
There are three ways a member can identify themselves — scan a QR code, type a PIN, or have someone pick their name out of a search — and one of them produces no code at all. Meanwhile there are four or five things that can happen next: they have a booking starting in ten minutes, they have no booking and the club offers a class list, they are on an open-gym membership, they owe money, the class is full.
Folding those together meant every new outcome touched every entry path.
/v1/kiosk/identify now takes a PIN, a code or a known contact id and returns
one thing: who this is, plus what they already hold. What happens next is the
screen's decision. The check-in flow itself works purely on contact ids and
has never heard of a QR code.
The screen only ever says what the server said
The staff kiosk uses a two-phase API: resolve the check-in to a target, show the operator what it found, then commit. The self-service app reuses both phases and drops the human in the middle — a resolved target is committed immediately.
What it does not do is decide the outcome. There are three result screens —
green for done, amber for "you are in, but there is a payment outstanding",
orange for "see the front desk" — and the variant is derived from the server's
response and nothing else. No client-side "this looks like an error". An
amber screen appears because the server said checked_in_payment_pending. A
member standing in front of a screen that disagrees with the ledger is worse
than one who is sent to reception.
The parts that only exist because it is a wall
Three of them are worth writing down.
The camera is app-lifetime and ref-counted. getUserMedia costs one to two
seconds of black screen on iPadOS, and the home screen is re-entered after
every single check-in. Worse, the shell deliberately remounts the current
screen on every idle reset — so a per-mount camera stream would tear the
device's camera down and re-acquire it on a timer, blinking the privacy
indicator all day. The stream is a React-free module with a retain count and a
twenty-second grace period, so a route change costs nothing and walking away
turns the camera off.
QR decoding has two back ends. The browser's native BarcodeDetector
where it exists, @zxing/browser dynamically imported everywhere else. Native
is free and far more tolerant of a phone held at an angle — and absent on every
iOS browser, which is most of the tablets this ships on. The native path is
gated on getSupportedFormats() rather than on the constructor existing,
because a constructor that exists and then resolves [] forever is a scanner
that silently never fires.
The background adapts to the club's photo. Clubs upload their own splash background, and a dark photo makes black text invisible. A single whole-screen light-or-dark decision is wrong somewhere on a real photo — bright wall at the top, dark floor at the bottom — so the image is sampled once into a small luminance field, and each cluster of UI reads the slice of the photo behind itself and themes itself from that. Every colour is a CSS variable, so regions morph between black and white ink instead of hard-cutting.
Where the browser ran out
By mid-August the web app was doing its job and the deployment story was not.
A tablet in a browser sleeps. It shows a camera permission dialog on page loads. Its address bar is one tap from becoming a web browser in a public room. It has no way to recover from its own renderer dying, which on an always-on device running a camera stream is a matter of when. None of these are product problems, and all of them are the difference between a demo and something that survives a year on a wall.
So: a React Native wrapper, on both platforms, rendering
https://{slug}.checkin.1club.ai in a full-screen WebView.
It carries no product logic. Not "a little" — none. Every screen a member or a staff member sees comes from the web app, which still deploys from CI on merge. What the wrapper carries is the four things a browser cannot do:
- Provisioning. Because the org comes from the host, the wrapper has to be told which one before it can load anything. The setup screen takes either a bare slug or a fully allow-listed hostname — the second form lets support point a production build at staging without shipping a hidden environment switcher. The environment itself is a property of the build, read from the update channel: the internal-distribution profile hits staging, a store build hits production, with no code branch and nothing to toggle.
- A navigation allow-list. Check-in hosts and the auth host, http(s) only, top-level navigations only. Everything else is refused rather than handed to the system browser. A kiosk must not become a launcher into Safari.
- Process-death recovery. On iOS,
onContentProcessDidTerminatereloads — theWKWebViewobject survives a jetsam, so the current page is kept. On Android the view is already dead whenonRenderProcessGonearrives, soreload()has nothing to act on and the fix is a key bump that remounts it. Getting these two backwards produces a white screen that stays white until a human walks past, which on a wall mount is hours. - One camera permission, once. Android's permission is requested before the
WebView mounts, which turns the web request into the silent grant path. On
iOS,
mediaCapturePermissionGrantType="grantIfSameHostElseDeny"replaces a dialog on every page load with the single OS prompt during setup.
Nearly every remaining prop on that WebView is load-bearing and several of them
match their defaults, which is exactly why they are written down with reasons.
incognito={false} is what stops every gym re-authenticating each morning, because
true swaps the disk-backed data store for one that is wiped on relaunch.
textZoom={100} is there because Android scales WebView text by the system
font-size setting and the check-in shell is a fixed seven-column grid — a break
you will never reproduce on your own device. And the app is deliberately not
scrollEnabled={false}, because on WKWebView that can break overflow: auto
descendants and the member search results are exactly that; bounces={false}
gets the same kiosk feel without the risk.
One thing the wrapper takes away: social sign-in. Google returns
disallowed_useragent for OAuth started in an embedded WebView as a matter of
policy, and punting to the system browser does not help either, because the
token would land in the system browser's storage and never reach the WebView.
The wrapper appends a marker to the user agent, the check-in app passes
embedded=1 to the auth app, and the auth app renders no social buttons. The
caller declares it rather than the auth app sniffing user agents — that keeps
the check on the side that actually knows about the wrapper.
The subsystem I deleted eight days after shipping it
The wrapper landed on 9 August with a screen-size orientation lock, and it was the most carefully built thing in the whole workspace.
The rule was simple to state: under 8.5 inches of screen diagonal, run portrait; at 8.5 and above, run landscape. Getting the diagonal is not simple. On Android you can compute it, because a dp is defined as 1/160 inch and the density cancels out of the arithmetic. On iOS you cannot: points-per-inch is 163 on iPhones and the iPad mini but 132 on every other iPad, so the naive calculation puts an iPad mini 6 at 8.47 inches — on the correct side of the line by three hundredths of an inch, which is luck, not engineering. So iOS was classified by family instead, using the bound that is adversarial to the decision: largest iPhone ever shipped, largest mini ever shipped, smallest non-mini iPad ever shipped. If the decision holds at the bound it holds for every member of the family. The module imported nothing at all so it could be unit-tested under plain vitest, and it had 292 lines of tests. It reported a confidence level and the margin in inches, so a shaky decision was visible rather than silent.
On 17 August I deleted all of it. Both platforms had withdrawn the ability it depended on:
- Android 16 / API 36 ignores
android:screenOrientation, aspect-ratio limits andsetRequestedOrientation()entirely on displays 600dp and wider. That is precisely the hardware class this app targets. The lock was not losing an argument with the OS; it was a silent no-op on every Android tablet. - iPadOS 26 deprecated
UIRequiresFullScreenwith no replacement, and declared orientations are ignored at launch and on rotation whenever the device's rotation lock is off. The one alternative Apple's technote points at is documented as a preference, not a guarantee.
The honest read is that the whole subsystem had been solving the problem in the wrong place for a month, and the platforms just made that impossible to ignore. The reason a 7-inch tablet needed forcing into portrait was that the layout only worked in landscape above 1024px. Fix that and there is nothing to force.
So the answer moved into CSS. Orientation is now not a layout input anywhere in
the app — no @media (orientation: ...), no JavaScript. orientation: landscape
means exactly width > height and carries no magnitude, so it is equally true
of a 1194×834 iPad and an 852×393 phone, two screens that need opposite
treatments. That is how the old rule got trapped: its width half was a proxy for
the thing that mattered and its orientation half silently switched every
landscape phone back to the portrait layout.
Instead, every vertical metric is clamp(min, min(<vw>, <dvh>), max), so it can
never outgrow either axis and rotating a device can only ever make it smaller.
The identification pane asks a container query rather than the viewport, because
that pane is the viewport minus the header minus the tab strip and the viewport
cannot answer for it. Every clamp cap equals the old desktop value, so the
tablets already on walls render byte-identical to what they rendered before,
while a portrait tablet finally gets the desktop scale instead of the phone one.
What is left on the native side is a deployment checklist, not code: mount the tablet, rotate it, then turn on the device's rotation lock, and enable Guided Access or screen pinning. Those are OS-enforced and need zero app code. The app follows the device and renders correctly either way up, which means a tablet showing the "wrong" orientation is now a device setting rather than a bug report.
Two apps out of one page
iOS went to TestFlight in mid-August, Android to the Play Console on the 18th. Two store listings, one bundle id per platform, one codebase that is a hundred lines of navigation policy and a WebView.
The discipline that costs the most attention is the publishing split. The
runtime version follows the app version, so a JavaScript-only change ships as an
over-the-air update and the version must not move, while anything native — a
dependency added or removed, an app.json change, an SDK bump — needs a full
build and a hand-written version bump. Nothing automates that bump; the
auto-increment setting moves the build number and the Android version code only.
Get it wrong and you have a new binary advertising an unchanged runtime version,
which makes it eligible for an already-published bundle that imports a native
module the binary no longer has. That fails before mount, no error boundary
catches it, and the symptom is a permanent white screen on a wall-mounted tablet.
The orientation deletion was exactly this shape — two native modules removed —
which is why it went out as 1.0.1 and a full build rather than an update.
There is a ship gate for the related failure: a script that boots the embedded release bundle and counts registered native modules, because Metro and OTA updates both mask registration failures. The count it checks against is written down along with what a healthy build produced and when, because a threshold with no provenance is a gate that passes vacuously the first time someone changes the dependency graph.
What the wrappers are actually worth
Roughly two hundred lines of TypeScript per platform, no product logic, and a release process I have to think about. In exchange: the screen never sleeps, the camera prompt happens once, a dead renderer heals itself in under a second, flaky gym wifi retries on a backoff ladder instead of sitting on an error, and the device is pinned inside one app by the operating system rather than by hiding UI.
Everything a member sees is still one Next.js app that deploys on merge like everything else we run. The two things in the App Store and Play Store are mounting hardware.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


