← All case studies

Salon POS & Franchise Management Platform

Project Director & Solution Architect · 2025 – Present

  • React 18 · TypeScript
  • NestJS
  • PostgreSQL · MikroORM
  • Auth0
  • Stripe Terminal
  • Socket.IO
  • Redis
  • MinIO
  • Vercel
  • Docker
  • i18next

Every location, live

A US salon chain was scaling into a franchise. It ran on the usual mix a growing business accumulates: consumer scheduling apps, POS terminals that each knew only their own counter, and customer records kept in whichever tool happened to touch them last. From head office there was no live picture of what any location was doing, which made it hard to manage services centrally or plan the next store on numbers anyone could trust.

The mandate was one platform for the whole chain, built for franchise scale rather than retrofitted into it. Four surfaces had to run off the same backend at once: an admin dashboard for franchise owners, a point-of-sale for the salon floor, a public booking site for customers, and a back office for configuration and reporting. Payments run on Stripe Terminal at the counter, so a checkout that stalls is a customer standing at the desk with a card in hand. The word that shaped the architecture, though, was “live.” A franchise owner watching one dashboard, and two front-desk staff at the same salon working off separate terminals, all had to see the same day as it happened.

A room per store

The easy way to get live updates is to push everything over a socket and hope the clients keep up. That falls apart the moment there is more than one store, because a check-in in Dallas has no business waking a terminal in Houston. So the realtime layer is partitioned by store from the first line.

Every terminal, once it knows which store it belongs to, sends that store id to the backend and joins a Socket.IO room named for it. An empty id gets the socket dropped rather than parked in some default room where it could leak another location’s traffic. When a new check-in lands on any terminal, the backend broadcasts to that one room, and only the terminals inside it hear anything.

check-in.gateway.ts TS
// One Socket.IO room per store, keyed by storeId. The broadcast carries no
// payload on purpose: it is a nudge, and each terminal refetches from Postgres,
// which stays the single source of truth. Scoping by store keeps a check-in in
// one salon from waking terminals in another.
@WebSocketGateway()
export class CheckInGateway {
@WebSocketServer() server: Server;

@SubscribeMessage('check-in')
async joinStoreRoom(client: Socket, storeId: string) {
  if (!storeId) {
    client.disconnect(); // no store means no room to belong to
    return;
  }
  await client.join('check-in-' + storeId);
}

emitNewCheckIn(storeId: string) {
  this.server.to('check-in-' + storeId).emit('new-check-in');
}
}

The broadcast that carries nothing

The broadcast is worth a closer look, because of what it leaves out. When a customer checks in, the backend writes the row and then emits new-check-in to the store room with no payload at all. It carries no state, only a signal to go and look. Each terminal that hears it refetches the check-in list from the API, so PostgreSQL stays the one place the truth lives and no client is ever rendering a check-in it assembled from a socket message that might have arrived out of order.

That keeps the moving parts honest. The gateway never has to serialize a check-in, terminals never drift from the database, and a terminal that reconnects after a dead spot pulls the current list on its next refetch instead of replaying a backlog of events it missed while it was gone.

Fig. 1 · Store-scoped check-in broadcast
POS terminal A NestJS backend Store's terminals each terminal emits check-in with its storeId and joins Socket.IO room check-in-STOREID (empty id is dropped) 1 · check-in customer phone lookup · same-day duplicate guard 2 · persistAndFlush(checkIn) new row in PostgreSQL 3 · new-check-in broadcast to room check-in-STOREID · no payload 4 · refetch check-in list each terminal re-pulls from the API 5 · current check-ins list re-renders on every terminal, live

When the same customer checks in twice

Two terminals at one desk will eventually try to check the same person in at the same time. Before it writes anything, the backend looks for an existing check-in for that customer at that store on the current day, read in the store’s own timezone, and refuses a second open one with a plain “already checked in” instead of creating a duplicate that staff would have to untangle at checkout. The timezone detail matters more than it looks, since a chain spread across zones cannot decide what “today” means from the server’s clock.

The other end of the day gets handled without anyone remembering to. A guest who never checks out would otherwise leave a check-in open forever, so a nightly job sweeps the previous day and closes whatever is still hanging, stamping it just before that store’s midnight so the books land on the right date. Deactivating a store or a service never hard-deletes it either. MikroORM soft deletes keep the row alive underneath the past invoices and reports that still reference it, so history stays intact while the thing drops off today’s menus.

Sequencing seven engineers

I directed seven engineers against a POS-first plan: a backend lead on the NestJS API and schema, three frontend engineers each owning a surface, a fullstack engineer bridging the back office, and a QA engineer holding integration coverage. Checkout throughput is where the salon earns, so a working POS on Stripe Terminal was milestone one, and the admin and booking surfaces shipped in parallel once the auth, store, and RBAC foundations were stable. Keeping all four apps in one monorepo behind shared ts-rest and Zod contracts meant a breaking change to an API route surfaced as a type error across every app before it reached staging, rather than as a bug a front desk found first.

Connectivity on a salon floor is never guaranteed, so the POS can hold a sale in progress locally through a network drop and complete it once the connection returns, so a busy counter never loses a sale to a dead spot. That resilience sat next to the realtime sync as the two things the floor would notice if they broke, which is why both were built and reviewed before go-live rather than bolted on after.

Outcomes

Engineering Outcomes

4 apps
one monorepo behind admin, POS, booking, and back office
ts-rest
shared Zod contracts type every route across the four apps
WebSocket
a check-in on one terminal refreshes the rest, store by store
Park-sale
an unfinished sale is held locally during a network drop; no lost sale

Directorial Outcomes

7 engineers
led across backend, three frontend surfaces, and QA
POS-first
checkout reliability sequenced before analytics depth
Monorepo
shared commits, lint, and CI from the first day
Franchise-ready
multi-store RBAC and analytics in the data model from kickoff