NailFriends — a two-app booking system for nail salons
Case study · May – September 2026 · solo build
A multi-tenant salon back-office on the web, a customer booking app on iOS, and one
availability engine shared between them through a versioned public API.
|
|
| Role |
Sole designer, architect and engineer |
| Duration |
2026-05-22 → 2026-09-14 (~4 months, part-time) |
| Surfaces |
Admin web app (nail-shop-admin), customer mobile app (NailFriends) |
| Stack |
TanStack Start · React 19 · Prisma 7 · PostgreSQL · better-auth · Expo SDK 57 · React Native 0.86 |
| Scale of code |
~25.7k lines TypeScript (admin) + ~6.1k (mobile), 35 test files, 508 translated strings × 3 locales |
| Status |
Admin running in Docker against Postgres; iOS app at v1.3.0 submitted to TestFlight; AR try-on engine is an open decision |
1. The problem
Small nail salons run on paper diaries, Instagram DMs and phone calls. Two things
break repeatedly:
- Double bookings. Availability isn’t one rule, it’s seven overlapping ones —
is the salon open, is that technician working, is she off, is the slot blocked,
does another appointment overlap, can she actually do a chrome set, and does the
requested service duration even fit before closing.
- Bookings arrive in a channel nobody owns. A DM is not a record. There is no
customer history, no no-show tracking, no reliable notice period for cancellations.
So the product is two-sided by necessity: staff need a back-office that is the
source of truth, and customers need a self-serve app that can only ever offer slots
the back-office would accept.
Constraint that shaped everything: the customer app must never be trusted. It
picks the slot, but the server decides whether the slot exists.
2. What I built
Admin web app — the source of truth
Nine feature modules behind an owner/manager/receptionist/technician role model:
- salon — settings, timezone, languages, deposit and cancellation policy
- auth — staff sessions, organization membership, RBAC
- services — catalog with fixed/from/range pricing and an
onlineBookable flag
- technicians — staff profiles and per-service skills
- working-hours — salon opening hours, per-technician hours, days off, blocked time
- customers — customer records, history, internal notes
- appointments — lifecycle: pending → confirmed → checked in → in progress → completed, plus cancelled / no-show
- availability — the slot engine
- calendar — day/week grid with drag-to-reschedule
Plus customer-auth and a public /api/public/v1 surface added later for the
mobile client.
Mobile app — NailFriends
Expo Router app with 32 screens: tabbed home, look gallery, a five-step booking
flow (service → staff → date → note → confirm), booking management, a loyalty
wallet, account screens, and an AR nail try-on flow.
3. Stack decisions, and why
TanStack Start over Next.js
I wanted end-to-end type safety from the database to the JSX without a separate API
layer or codegen step. TanStack Start’s createServerFn gives a typed RPC boundary
where I control validation explicitly, and TanStack Router gives fully typed route
params and search params — including typed loader data. The tradeoff I accepted: a
v1 framework with a smaller ecosystem and fewer answers on Stack Overflow. That cost
showed up in real ways (Vite allowedHosts and auth trusted-origin configuration
both needed dedicated commits), but the payoff was that a Prisma schema change
surfaced as a type error in a component rather than as a runtime 500.
Modular monolith over microservices
A single deployable, but with enforced internal seams. Every feature lives in
src/modules/<feature>/ and exposes exactly three things:
modules/<feature>/
index.ts # PUBLIC API — the only legal import surface
schema.ts # Zod schemas — the contract between server and UI
server/ # createServerFn handlers + business logic
components/ # React
README.md # responsibility, dependencies, tasks
The rule is one line long: import another module only via its index.ts. Never
reach into another module’s server/, components/ or schema.ts. That single
constraint is what made it possible to build modules in parallel (see §5) and what
kept the refactors later in the project cheap.
Prisma 7 + PostgreSQL, with tenancy as a helper not a convention
Every tenant table carries salonId with an index, and every tenant query goes
through withSalonScope(salonId, where) from shared/db/tenant. Making it a
function rather than a code-review rule means tenant leakage is a visible omission
in a diff, not an invisible one. The Prisma schema is sectioned by module
(// MODULE: services) so parallel work on it produced predictable, non-overlapping
diffs.
better-auth with the organization plugin
An organization is a salon. Membership gives tenancy and role in one object, so
“which salon am I in” and “what may I do here” come from the same session rather
than from a bolted-on salons table.
Compile-time i18n (Paraglide) over runtime i18n
The target market is French salons with Vietnamese-speaking staff and English
tourists, so three locales were a requirement, not a nice-to-have. Paraglide
compiles messages into tree-shakeable functions: a missing key is a TypeScript
error, and unused strings don’t ship. 508 keys across en / fr / vi. The cost
is a generated, gitignored src/paraglide/ directory that has to exist before
tsc runs — solved with a pretypecheck/postinstall hook rather than by
committing build output.
Expo + React Native for the client, not a mobile web app
The try-on feature needs the camera and, eventually, a native AR SDK. Expo Router
gives file-based routing that matches the web app’s mental model, EAS handles iOS
signing and TestFlight submission, and expo-secure-store puts the session token
in the keychain instead of AsyncStorage.
Bun as package manager, Node as runtime
bun install and script running for speed; Node stays the runtime for Vite and
TanStack Start, including in Docker (a node:22 image with the bun binary copied
in). A mid-project commit — “Replace completely npm by bun in the system” — made
this consistent rather than half-migrated, which is usually where this kind of
choice goes wrong.
4. How it was built — the actual timeline
Reconstructed from git: 65 commits on the admin app, 17 on the mobile app.
Phase 0 — Mobile prototype first (May 22 – 23)
The mobile app is the older repo. Before any backend existed, I scaffolded
NailFriends with tabs, a booking flow and a loyalty card, all on local mock data,
then pushed it far enough to have a confirmation screen with calendar integration.
Building the consumer surface first was deliberate: it forced the data model to be
designed against a real screen rather than against a guess. By the time I wrote
schema.prisma, I already knew what a booking flow actually asks for.
Phase 1 — Design before data (June 2 – 3)
A written design brief (DESIGN_BRIEF.md) fixed a single aesthetic — “Coastal Spa”:
Fraunces display serif, Manrope for UI, glass surfaces, soft teals and sand — and
explicitly forbade business logic during the UI pass. First commit: “Run design
brief and implement design for each module.” Mock data and useState only.
Then a 32KB architecture spec, then the foundation modules: auth, salon, shared
tenancy helpers, Docker dev setup.
Doing the whole UI first, with no data, meant the design never got quietly
compromised by plumbing decisions — and it meant the module seams were already
visible before any of them had a server function.
Phase 2 — Core modules in parallel (June 3 – 4)
Services, technicians and customers have no dependencies on each other, only on
salon. They were built concurrently against their schema.ts contracts. Commit
“Implement salon, auth, service, technician, customer module” closes the wave.
Phase 3 — The calendar, spec-driven (June 4)
The largest single feature and the most disciplined stretch of the project. The
sequence in git is exact:
docs: add option a calendar design spec ← 9KB design
docs: add calendar real-data implementation plan ← 55KB plan
feat: share technician display helpers
feat: enrich appointment list items with technician names
feat: add calendar interaction math ← pure functions
feat: support appointment form defaults
feat: wire calendar to real appointment data
fix: require drag threshold before calendar reschedule
test: guard calendar against mock data imports
Two details worth pointing at. Calendar interaction math was extracted into pure
functions and committed before the UI was wired — pixel-to-time conversion and
drag snapping are exactly the kind of logic that is miserable to debug inside a
component and trivial to unit test outside one. And a test that fails if the
calendar imports mock data: the migration from mock to real data is enforced by
CI, not by memory.
The fix: require drag threshold before calendar reschedule commit is the giveaway
that this was used, not just built — a click was being read as a one-pixel drag and
silently rescheduling appointments.
Phase 4 — Localization and design polish (June 5 – 6)
Three commits localizing dashboard, calendar, customers, appointments, services,
technicians and working hours. Retrofitting i18n is painful; it’s included here
because it’s honest — doing it per-module after the fact was slower than writing
the strings localized would have been.
Phase 5 — Opening the API to the mobile app (June 30 – July 6)
The most security-sensitive work in the project, and the most carefully sequenced.
A design spec, then a 66KB implementation plan, then fifteen commits in dependency
order:
feat(db): onlineBookable, Customer.userId, User.principalType
feat(config): getPublicSalonId resolver
feat(contracts): client-safe public API Zod schemas
feat(services): onlineBookable flag + public catalog handler
refactor(availability): extract computeAvailabilityForSalon(salonId, …)
refactor(appointments): extract bookAppointmentCore from createAppointment
feat(customer-auth): requireCustomer guard + signUpCustomer linking
feat(public-api): json + error-envelope route helpers
feat(public-api): wire services/technicians/availability/me/sign-up
fix(public-api): stop internalNote leak, enforce onlineBookable/visibility
docs: record customer-auth module + public API surface
Three things I’d call out as the good decisions here:
The two refactor commits come before the feature commits. Public booking had to
run the same availability and booking logic as the admin app, not a parallel
implementation that would drift. So the shared cores were extracted first, and the
public endpoints became thin callers.
Clients cannot choose a salon. getPublicSalonId resolves the tenant from
server-side config (PUBLIC_SALON_SLUG). There is no salonId parameter on any
public endpoint to tamper with — a whole class of tenant-crossing bug removed by
API shape rather than by validation. A later commit, “Remove salon id api,”
finished the job.
Separate, client-safe response schemas. The admin Service type and the public
one are different Zod schemas, which is what made fix(public-api): stop internalNote leak a one-line fix at a single seam instead of an audit of every
handler. That commit is also a fair illustration of the risk: a shared model would
have leaked staff-only notes to customers, and I caught it because there was one
place to look.
The API is documented in a hand-maintained openapi.yaml (9 endpoints), which also
served as the mobile app’s build contract.
Phase 6 — Mobile app against the real API (July 1 – 6)
feat: Implement booking context with API integration, then immediately
feat: Update date formatting functions and add timezone handling. Then auth with
guest browsing and post-login redirect, then v1.1.0.
Phase 7 — Hardening and shipping iOS (Aug – Sep 14)
feat: remake completely UI components for looks and booking management — a full
visual second pass once the flows were proven
- Expo SDK 57 upgrade, React Native 0.86
fix(ios): add missing Info.plist purpose strings; bump to 1.3.0 — the classic
App Store rejection: camera, photo library and location all need human-readable
purpose strings
chore(eas): set ascAppId for non-interactive iOS submission — CI-able
eas submit
- Admin side: real seeded working hours replacing the last fake data, remember-me on
login, and an error-handling/user-feedback pass across components
Rework tab bar (Home, Gallery, AI Try-on, Booking, Profile) — the last commit,
promoting try-on to a primary tab
5. Working with AI agents as a delivery method
This project was built with Claude Code, and the workflow is part of the case study
because it’s why a solo build has module READMEs, 32–66KB specs and a documented
module boundary rule.
The loop was spec → plan → implement → test, per module:
- A design spec in
docs/specs/ — what and why, no code.
- An implementation plan in
docs/plans/ — ordered, verifiable steps.
- Implementation against the
schema.ts contract.
- Tests, then the next module.
CLAUDE.md at the repo root encodes the rules that make this work: module
boundaries, contract-first Zod schemas, tenancy through withSalonScope,
availability as pure functions. A docs/ tree and per-module READMEs (owner,
dependencies, tasks-from-spec) keep the state of the project legible between
sessions.
What actually made the difference: the module boundary rule and the schema.ts
contracts. Parallel work across modules only stays coherent if each one has a
narrow, written interface — and that’s equally true whether the parallel workers are
agents or people. The architecture discipline wasn’t overhead on top of the AI
workflow; it was the thing that made the AI workflow produce something maintainable.
I still own every architectural decision in §3, the security model in §5’s public
API phase, and the calls documented in §7. The specs exist because I had to write
down what I wanted precisely enough to hand off — which is not a bad habit to be
forced into.
6. Engineering problems worth showing
The availability engine
Seven independent rules, one answer. The core is pure functions with no database
access — it receives working hours, appointments and services as inputs, which
makes every rule unit-testable in isolation. The test file is 23.7KB against a
15.5KB implementation.
It also does something more useful than returning a boolean: every unavailable slot
carries a typed reason.
export const availabilityConflictReasonSchema = z.enum([
"salon_closed",
"technician_unavailable",
"technician_day_off",
"blocked_time",
"appointment_overlap",
"missing_skill",
"duration_invalid",
]);
The schema enforces the invariant in both directions — an unavailable slot must
have a reason, an available slot must not have one:
if (!slot.available && slot.conflictReason === undefined) {
ctx.addIssue({ message: "Unavailable slots require a conflictReason", ... });
}
So the UI can say “Mai is off on Mondays” instead of “no slots available,” and it
can say it in three languages, because the reason is an enum and not a string.
Timezones — the bug I wrote a document to prevent
Salons keep wall-clock time. Devices keep their own. A customer whose phone is in
another timezone — traveller, expat, wrong clock — will see the wrong appointment
time, and nothing will crash. I wrote a 10KB integration guide
(CLIENT_APP_TIMEZONE_GUIDE.md) before writing the client’s date handling. Five
rules:
- The wire is always UTC, ISO-8601,
Z-suffixed.
- Display in the salon’s timezone, not the device’s.
- The
date passed to availability is a salon-local calendar day, never derived
from the device clock.
- To book, send the slot’s
startAt back verbatim.
- The server owns the conversion.
This is the kind of defect that ships, survives QA, and gets reported months later
as “the app is wrong sometimes.” Writing the rules down first was cheaper than
debugging it once.
The staff / customer privilege wall
Customers and staff are both better-auth users. The distinction is
principalType plus “no active organization” — a single seam carrying the entire
privilege boundary. I documented it in CLAUDE.md as a known security-sensitive
area with an explicit pre-ship audit checklist rather than declaring it done:
- a customer session must never reach a staff endpoint, or vice versa
- customer mutations must only touch that customer’s own records — ownership, not
just authentication
- public endpoints need rate limiting against booking spam and enumeration
That TODO is still open, and it’s in the case study on purpose. Knowing where your
single point of security failure is, and saying so, is a more useful signal than a
clean README.
AR nail try-on — the decision I didn’t make
The try-on flow in the app is fully built: camera feed, lens carousel, hint pills,
capture ring, result screen. The AR is simulated — tapping the hand guide fakes a
“tracking lost” state. The UI is done; only the engine is missing.
Rather than commit budget on instinct, I wrote a 14KB decision brief comparing two
Snap Camera Kit integration paths, with the already-settled questions separated from
the open ones (we author our own Lenses in Lens Studio; iOS first; v1 is live
preview + lens switching + real tracking state + photo capture, no video; fallback
is the existing plain expo-camera screen). It’s explicitly marked as seeking
review from engineers with production React Native + native SDK experience, and it
names the real blocker: Snap’s hand-and-nail segmentation is still beta.
Shipping a convincing UI behind an undecided engine, with a documented degraded
fallback, is a deliberate sequencing choice — the product is demoable and the
expensive decision stays open.
7. Honest assessment
What went well
- The design-first pass produced one coherent product across nine modules and two
platforms.
- Extracting shared cores (
computeAvailabilityForSalon, bookAppointmentCore)
before adding public endpoints meant admin and customer booking can’t drift.
- Pure-function boundaries around availability and calendar math made the two
hardest pieces of logic testable and the tests cheap to write.
- Separate public Zod schemas turned a data-leak class of bug into a one-line fix.
- Tenant resolution from server config removed a whole category of multi-tenant
vulnerability by API shape rather than by validation.
What I’d do differently
- i18n from the first line. Retrofitting 508 keys across seven modules cost
more than writing them localized would have.
- Security review before the public API shipped, not after. The audit checklist
exists; the audit doesn’t.
- A framework this young needs an escape hatch. Several days went to TanStack
Start + Vite + better-auth integration issues (
allowedHosts, trusted origins,
Docker networking) that a mature stack would have documented. I’d still choose the
type safety, but I’d budget for it.
- Four commits in the final week are still pulling out fake data. Seeding real
data per module as it was built would have avoided a cleanup tail.
Known open items
- Public API security audit and rate limiting (documented, not done)
- AR try-on engine undecided; UI ships against a simulated engine
- Android build not wired
- Some content — salon profile, look gallery, loyalty wallet — is intentionally
build-time static, because the API has no endpoint for it yet. The code says so
explicitly: “Anything the API does serve is fetched, never mocked.”
8. Numbers
|
|
| Admin app |
~25.7k lines TypeScript, 9 feature modules, 35 test files |
| Mobile app |
~6.1k lines TypeScript, 32 screens, 15 shared UI components |
| Public API |
9 documented endpoints, hand-maintained OpenAPI 3.0.3 spec |
| Localization |
508 message keys × 3 locales (en / fr / vi) |
| Specs & plans |
7 documents, ~180KB of written design before implementation |
| Commits |
65 (admin) + 17 (mobile) |
| Availability engine |
15.5KB implementation, 23.7KB of tests, 7 typed conflict reasons |
| Onboarding cost |
bun start — Postgres, migrations, seed and app in one command |