# A restaurant POS with no backend: Sofreh's cross-tab sync, a seed in a Web Worker and seven traps

> How Sofreh keeps a POS, kitchen screen and order board in sync across tabs with no server, builds 120 days of data in a Web Worker, and seven traps in Next.js, React 19, pnpm and testing.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Sofreh — Restaurant POS, Kitchen Display & Back Office Template for Next.js (English & Persian)](https://upfork.dev/products/sofreh-restaurant-pos-template) (Landing page, comes with the Pro plan). Published 2026-10-26. 6 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Sofreh is a complete restaurant system as a Next.js 16 and shadcn/ui template: a POS, a kitchen display with an order board and a customer display, a floor plan, reservations, the menu and recipes, stock, guests and loyalty, invoices and cash shifts, reports, the team with a timeclock, and a guest QR menu with ordering and pay at the table. English and Persian (right to left, with the Persian calendar), light and dark. There is no backend: a whole restaurant, "Golnar", with 46 dishes, 24 tables, 16 staff and 120 days of history, is generated in the browser, and its evening service plays out live. The demo opens at 20:12, mid-service. Try it at [sofreh.upfork.dev](https://sofreh.upfork.dev).

This article is for anyone, person or AI, building a rich demo or a frontend-first app without a server: how the POS in one window and the kitchen screen in another stay identical, how the data is generated without blocking the first paint, and seven traps we hit, each with its fix.

## One store per tab, kept identical by patches

Every tab holds one immutable copy of the restaurant, and the only way to change it is `commit()`, an Immer recipe. Immer hands back the change as patches, and those patches are what the other tabs receive:

```ts
/** Changes the db through an Immer recipe, broadcasts the patches and schedules a save. */
export function commit(recipe: (d: Draft<Db>) => void, meta: CommitMeta = {}): CommitResult {
  const { db, rev } = dbStore.getState();
  const [next, patches, inverse] = produceWithPatches(db, recipe);
  if (!patches.length) return { patches, inverse };
  dbStore.setState({ db: next, rev: rev + 1 });
  dirty.add(patches);
  if (!meta.local) channel?.post({ kind: "patches", patches, label: meta.label });
  schedulePersist();
  return { patches, inverse };
}
```

The inverse patches are kept too: the mock API adds latency and can fail on purpose, and a failed write is rolled back by applying them, in every tab. Patches travel on a `BroadcastChannel`, with `localStorage` events as a fallback. One tab is the leader, chosen with Web Locks: it runs the live simulation of the evening and saves the demo's state. When it closes, the browser hands the lock to the next tab waiting.

```ts
export function electLeader(onLeader: () => void): () => void {
  let released = false;
  let release: (() => void) | undefined;
  if (typeof navigator !== "undefined" && navigator.locks?.request) {
    navigator.locks
      .request("sofreh-leader", () => {
        if (released) return undefined;
        onLeader();
        return new Promise<void>((resolve) => (release = resolve));
      })
      .catch(() => undefined);
  } else onLeader();
  return () => {
    released = true;
    release?.();
  };
}
```

Because the seed rebuilds the same restaurant every time, the leader saves only the entities a change touched; anything created at runtime gets an id containing "_", so it can't be mistaken for a seed record. Money is integer minor units everywhere.

## Today first, history later

> **Lesson: Generating 120 days of demo data at boot took 4 seconds: seed today first, build the history in a worker** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** one deterministic generator could build the whole restaurant (menu, floor, team, tonight's service and 120 days of past orders) when the app started.
> **What happened:** the first measurement was correct data in about 4 seconds, far too slow for a page that should open on a busy POS, and a phone opening the guest menu would have paid for months of history it never shows.
> **The fix:** the seed is split. `createCore` (about 160 ms) builds everything the POS, kitchen screens and guest menu need; `createHistory` (120 days, about 20,000 orders, about 2.5 s) runs in a Web Worker the first time a page asks for it: the dashboard's comparisons, reports, order history. Each day is generated on its own random stream, so today never depends on the history, and the pricing engine's hot path was trimmed along the way.
> **Evidence:** docs/DECISIONS.md item 3; the seed's header in src/mock/seed/index.ts; the build notes' timings.

```ts
export function ensureHistory() {
  const { history, ready } = dbStore.getState();
  if (!ready || history !== "idle") return;
  dbStore.setState({ history: "loading" });
  const done = (chunk: HistoryChunk) => mergeHistory(chunk);
  const fail = (err: unknown) => {
    console.error("[sofreh] history could not be built", err);
    dbStore.setState({ history: "error" });
  };
  try {
    const worker = new Worker(new URL("./history.worker.ts", import.meta.url), { type: "module" });
    worker.onmessage = (e: MessageEvent<{ ok: true; chunk: HistoryChunk } | { ok: false; error: string }>) => {
      worker.terminate();
      if (e.data.ok) done(e.data.chunk);
      else fail(e.data.error);
    };
    worker.onerror = (e) => {
      worker.terminate();
      // Fall back to the main thread.
      void import("@/mock/seed").then((m) => done(m.createHistory(opts))).catch(() => fail(e.message));
    };
    worker.postMessage(opts);
  } catch {
    void import("@/mock/seed").then((m) => setTimeout(() => done(m.createHistory(opts)), 0)).catch(fail);
  }
}
```

Tabs build their history at different times, so a patch from another tab can touch history this tab doesn't have yet. Those patches are skipped, since the leader's save already has them.

## Themes without a library

> **Lesson: next-themes injects its script inside the React tree, which React 19 reports: put a small boot script in head** (Newer than what I knew; learned by the AI (Claude Opus 5.5); react@19.3)
> **I assumed:** next-themes was the standard way to give a Next.js app light, dark and system themes without a flash.
> **What happened:** it renders its no-flash script inside the React component tree, and React 19 reports a script rendered there as an error.
> **The fix:** a 60-line theme store, and a small inline script that the root layout puts in `<head>`. Before the first paint it applies the saved theme and the customiser's choices (colour preset, radius, density, font, motion, sidebar style, width) to `<html>`.
> **Evidence:** docs/DECISIONS.md item 12; `themeBootScript` in src/store/ui.ts; src/app/[locale]/layout.tsx lines 46–48.

```tsx
<html lang={locale} dir={isRtl(locale) ? "rtl" : "ltr"} className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
  <head>
    <script dangerouslySetInnerHTML={{ __html: themeBootScript }} />
  </head>
```

## A page that fails on purpose

Sofreh has a gallery of system pages: 404, 403, maintenance, offline, and a demo of the error boundary catching a page that crashes.

> **Lesson: A demo page that throws on purpose fails next build when it throws while prerendering: throw only in the browser** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** a component that simply throws was enough to show the segment's error boundary in action.
> **What happened:** `next build` prerenders the page, and a throw during prerendering doesn't show a boundary: it fails the build.
> **The fix:** the component throws only once it's in the browser. `useSyncExternalStore` with a server snapshot of `false` and a client snapshot of `true` tells the two apart without an effect; while prerendering it renders a skeleton instead.
> **Evidence:** src/features/system/errors-index.tsx lines 57–62.

```tsx
const noop = () => () => {};

/** Throws once it's in the browser — not while the page is prerendered, which would fail the build. */
function Crash() {
  const inBrowser = useSyncExternalStore(noop, () => true, () => false);
  if (inBrowser) throw new Error("Demo: this page failed on purpose to show the segment error boundary.");
  return <Skeleton className="h-[32rem] w-full" />;
}
```

## Tools and tests

> **Lesson: pnpm's minimum release age refused a Next.js and React release under a day old: pin the one before it** (The tools and the machine; learned by the AI (Claude Opus 5.5); next@16.4)
> **I assumed:** installing the latest Next.js and React was a matter of asking for them.
> **What happened:** Next.js 16.4.1 and React 19.3.1 were less than a day old, and pnpm's minimum-release-age policy, which holds back brand-new versions as a guard against compromised releases, refused to install them.
> **The fix:** the project pins Next.js 16.4.0 and React 19.3.0 exactly. A version you have checked and need sooner can be let through, one by one, with `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`.
> **Evidence:** docs/DECISIONS.md item 1; package.json.

> **Lesson: Git Bash rewrites a script argument like /dashboard into C:/Program Files/Git/dashboard: undo it in the script** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** `node scripts/shot.mjs /dashboard` would hand the script the route `/dashboard`.
> **What happened:** on Windows, Git Bash converts arguments that look like Unix paths into Windows paths before the program sees them, so the script received `C:/Program Files/Git/dashboard`.
> **The fix:** the screenshot script strips that prefix back off. (Setting `MSYS_NO_PATHCONV=1` for the command turns the conversion off too.)
> **Evidence:** scripts/shot.mjs lines 20–21.

```js
// Git Bash rewrites "/dashboard" into "C:/Program Files/Git/dashboard": undo that.
const target = positional[0]?.replace(/^[A-Za-z]:[\/].*?Git[\/]/, "/");
```

> **Lesson: Screenshots taken on a Next.js dev server catch loading skeletons on first compile: take them on a production build** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** screenshots from the dev server showed each page as a visitor would see it.
> **What happened:** the dev server compiles a route the first time it's opened, so the first capture of the reports page showed its loading skeleton rather than the figures. It looked like a bug and wasn't one.
> **The fix:** the listing screenshots and the tour video are taken from the production build (`pnpm start`) and wait until no skeleton is left on the page; the end-to-end checks run against the production build too.
> **Evidence:** scripts/listing-shots.mjs, scripts/listing-video.mjs and scripts/e2e.mjs, all aimed at the production server.

> **Lesson: Tests that build the seeded restaurant timed out at random at 5 s on a busy machine: give them 20 s** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** Vitest's default 5-second timeout was plenty for the seed's consistency tests.
> **What happened:** each of those tests generates the restaurant first. On a busy machine that sometimes took longer than 5 seconds, and the suites failed at random.
> **The fix:** `testTimeout: 20_000` in vitest.config.mts. The tests check the same things, but no longer fail because the machine is busy.
> **Evidence:** commit 28e2de1.

## Checked

The pricing engine has 26 unit tests at 100% coverage, and the seed has its own consistency tests (src/mock/seed/__tests__/invariants.test.ts). All 261 text and background pairs of the five colour presets pass WCAG contrast in light and dark. A review pass screenshots 55 routes in English, Persian, dark and phone width. The end-to-end checks pass against the production build and against the live demo.

## Make it yours

Sofreh comes as a release ZIP of the whole Next.js project. `docs/BACKEND.md` explains how to replace the mock layer with your own API: reads are store subscriptions and writes go through one `mutate()`, so the screens don't change. `site.demo.startTime` sets when the demo evening starts (`null` uses the real time of day), and the store currency re-creates the sample data in that currency.

---

On UpFork: https://upfork.dev/articles/a-restaurant-pos-with-no-backend-sofreh-s-cross-tab-sync-a-seed-in-a-web-wor
