
Loading the page…
Loading the page…
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.
Written with AI: Claude Opus 5.5, through Claude
7 lessons learned building it
The product
Sofreh — Restaurant POS, Kitchen Display & Back Office Template for Next.js (English & Persian)Pro plan · Landing page

The product
Pro plan · Landing page
A complete restaurant system for Next.js and shadcn/ui: POS, kitchen display, floor plan, reservations, menu and recipes, stock, guests and loyalty, invoices and shifts, reports, team, and a guest QR menu with ordering and pay at the table. English and Persian, light and dark.
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.
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.
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:
/** 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.
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.
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.
Sofreh has a gallery of system pages: 404, 403, maintenance, offline, and a demo of the error boundary catching a page that crashes.
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.
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.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #155, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Parley — Team Chat App for React, shadcn/ui & Tailwind (Messenger UI)
How Parley keeps the open chat in the URL under Next 16, and ships its 22 Base UI primitives inside the component, under UpFork's 400,000-character cap.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
3 lessons


Behind the build · Wisp — AI Chat App Template for Next.js, shadcn/ui & Tailwind (Streaming, Research, Canvas)
How Wisp keeps a 520-message chat scrolling with Shiki in a Web Worker, persists to IndexedDB without crashing next start, and eight traps with blur, scrollbars and code blocks.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
8 lessons


Behind the build · Rapport — Contacts App & CRM for React, shadcn/ui & Tailwind (Address Book UI)
What we learned building Rapport, a contacts app and light CRM on shadcn/ui with Base UI: six traps in the shadcn CLI, Base UI, Intl.Collator, libphonenumber and avatar loading.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
6 lessons
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 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.
docs/DECISIONS.md item 3; the seed's header in src/mock/seed/index.ts; the build notes' timings.
it renders its no-flash script inside the React component tree, and React 19 reports a script rendered there as an error.
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>.
docs/DECISIONS.md item 12; themeBootScript in src/store/ui.ts; src/app/[locale]/layout.tsx lines 46–48.
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.
src/features/system/errors-index.tsx lines 57–62.
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.
docs/DECISIONS.md item 1; package.json.
the screenshot script strips that prefix back off. (Setting MSYS_NO_PATHCONV=1 for the command turns the conversion off too.)
scripts/shot.mjs lines 20–21.
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.
scripts/listing-shots.mjs, scripts/listing-video.mjs and scripts/e2e.mjs, all aimed at the production server.
testTimeout: 20_000 in vitest.config.mts. The tests check the same things, but no longer fail because the machine is busy.
commit 28e2de1.
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);
}
}<html lang={locale} dir={isRtl(locale) ? "rtl" : "ltr"} className={`${GeistSans.variable} ${GeistMono.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeBootScript }} />
</head>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" />;
}// Git Bash rewrites "/dashboard" into "C:/Program Files/Git/dashboard": undo that.
const target = positional[0]?.replace(/^[A-Za-z]:[\/].*?Git[\/]/, "/");
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment