Skip to content

Repository files navigation

Table-side ordering for restaurants

Self-hosted table-side ordering, built in the open under AGPL-3.0. A guest opens the code printed on their table, sees the menu, and sends a round to the kitchen from the page — no app to install, and no request to any origin but the one that served it.

Status: 2026-08-27 · a member of staff signs in on a page of their own, reads every open order in their restaurant, records a round as paid for, and clears a ticket from the board when the kitchen has served it.

CI

The Blue Door's menu on a guest's phone: the restaurant's name, the table's label beneath it, then each item with its price and a box for how many. Below the send button the page says the order is with the kitchen, and under that is what this table has already sent, each round with its quantity and no price beside it.

The guest's page, captured at a8f828f. The picture links to the v0.1.0 release, whose one asset is a 32 s take of the whole loop — a guest sends a round, a member of staff signs in, and the round is on the board — produced by tools/record-demo.ts.

Why

Every table-ordering product I looked at wanted a percentage of card volume, a tablet on every table, or both — and most of what they were charging for was a menu on a screen. The menu is not the hard part. The hard part is making one guest's order survive a retry, a dropped signal, and a kitchen screen that somebody else is updating at the same moment. That is worth building carefully, and it is worth being able to run yourself.

Run it

Requires Node 24 (see .nvmrc), pnpm, and Docker for PostgreSQL.

git clone https://github.com/sebkoo/table-ordering.git
cd table-ordering
pnpm install
docker compose up -d

PostgreSQL is published on host port 55432 rather than 5432, so that it comes up beside whatever PostgreSQL you already run. If 55432 is taken, change it in compose.yaml and set DATABASE_URL to match.

Create the schema. There is no migration runner yet, so this is psql reading each migration in turn, on a database that has had none of them — re-applying one raises relation already exists, and nothing is applied — the loud failure the absence of a runner rests on. --single-transaction is not optional: without it psql commits each statement as it goes, and a file that failed halfway would leave the half behind (ADR 0015):

for m in services/api/migrations/*.up.sql; do
  docker compose exec -T postgres \
    psql -U table_ordering -d table_ordering --single-transaction < "$m"
done

Give it a restaurant to serve, and a table to sit at. There is no admin route yet either. The code is the address the table's card will carry, so mint it rather than choose it — openssl rand -hex 6 produced the one below — and do not name the table in it.

The flag is not optional here either, and it buys more than it does above. restaurant and restaurant_table each carry a unique constraint that stops a second run; menu_item carries none. Without --single-transaction a repeated run therefore leaves the item behind on its own, duplicated, while the two inserts around it fail — and psql exits 0 having reported the failures on stderr and nothing about what it kept:

docker compose exec -T postgres \
  psql -U table_ordering -d table_ordering --single-transaction <<'SQL'
insert into restaurant (slug, name) values ('blue-door', 'The Blue Door');
insert into menu_item (restaurant_id, name, price_minor, currency, sort_order)
select id, 'Flat white', 300, 'GBP', 10 from restaurant where slug = 'blue-door';
insert into restaurant_table (restaurant_id, code, label)
select id, '9f3c1a7b20de', 'Table 7' from restaurant where slug = 'blue-door';
SQL

Then start the API and ask it for that table's menu:

pnpm dev
curl -s localhost:3000/tables/9f3c1a7b20de/menu
{"restaurant":{"slug":"blue-door","name":"The Blue Door"},"table":{"label":"Table 7"},"items":[{"id":"8f14e45f-ceea-467a-9f0b-2c2e0a3f7c31","name":"Flat white","priceMinor":300,"currency":"GBP"}]}

What's here

The roadmap below is complete — every row Done — and the depth is one link from it: what happens at the table, how a request is served, the run steps in full, the decisions in docs/adr/, and the limitations in docs/known-limitations.md.

A moving picture of the loop is produced by tools/record-demo.ts. The producer is in this tree; what it emits is not.

What it looks like

The pages, and the loop between them: a guest sends a round from the table, a member of staff signs in, and the round is on the board. Each picture is a capture of the running product, and its caption names the revision it was taken at.

The board's sign-in on a wider screen: a field for an email address, a field for a password whose characters the browser has replaced with dots, and a button to sign in.

The board's sign-in, captured at a8f828f. What was typed into the password field is masked by the browser, and no value from it is in the picture.

The open-orders board on a wider screen: the heading, the name of whoever is signed in and the restaurant they work at beneath it, then a row per ticket with the table on the left and what was ordered beside it, oldest at the top.

The board, captured at 0fe409d.

What happens at the table

The API serves a restaurant's menu, takes an order from a table's printed code, reads that table's own orders back, and answers a staff session with the restaurant's open orders — and the guest's page and the board are what a phone and a kitchen screen see of it. The whole narrative, request by request and answer by answer, is in docs/what-happens-at-the-table.md.

How a request is served

Two paths through the code, and both are one transaction of the same shape: the printed code resolves to a restaurant, the scope is set on that transaction from the row the resolve returned, and every statement after it names no restaurant at all. The diagrams that trace the menu read and the order write layer by layer — route, transaction, policy, PostgreSQL, and what each one answers — are in docs/how-a-request-is-served.md.

Roadmap

Every row is Done. That is a statement about this list and not about the product being finished: the list is what was planned when it was written.

Step State
Toolchain, convention checks, CI Done
Tenant schema Done
Guest menu, over HTTP Done
A page the guest's phone loads Done
A table's own code, on the guest's page Done
Order submission over HTTP, tolerating retries Done
Row-level security on a write, so scope is not the query's job Done
The guest's page sends the order Done
A table's own orders, read back under the policy Done
The guest's page shows what the table has sent Done
A member of staff can prove who they are Done
The restaurant's open orders, read under a staff session Done
The board on a page staff sign in to Done
Row-level security on a read, so a menu query drops its scope too Done
A kitchen board a ticket can be acted on from Done
Payment, as an option rather than a requirement Done

Run it in full

The quickstart above is enough to see a menu. The rest of the walkthrough — the role the API connects as, ordering and reading back with curl, a member of staff, the two pages, and what the checks do — is in docs/run-it-in-full.md.

Decisions

Architecture decisions are in docs/adr/, one file per decision, each with the alternatives that were rejected and why.

Known limitations

Every limitation this project knows about is written down at length in docs/known-limitations.md — what a second opening of a printed code can do, what a table's code discloses to whoever holds it, what the board cannot do yet, and what each convention rule is blind to. It is the honest half of every claim this project makes, and it is a document for the same reason the two above it are: the depth belongs in docs/, where a rule can still read it.

Non-goals

  • Replacing a restaurant's point-of-sale system.
  • A delivery marketplace, or anything that puts a third party between the restaurant and its guest.
  • An app for guests to download.
  • Taking a percentage of the money that moves through it.

Money

This platform takes zero basis points of card volume. That is a statement about what this software charges, not a claim about what a restaurant pays overall: the restaurant still pays its own card processor, whoever that is.

A round can be recorded as paid for, and nothing requires it. That is the whole of what is built: a moment on the order, written by a member of staff, read by the board and by nobody else. There is no amount, no currency and no card — recording a fact is not handling money, and an amount would be a ledger, which is its own decision.

Payment handling is not built and no processor is planned. Routing a self-hosted restaurant's guests through somebody else's checkout is the thing the sentence "no third-party requests" rules out, and it is the reason the option is a fact to record rather than a flow to run (ADR 0036). If this ever needs to fund itself, the honest routes are hosting, support and integration work — the same routes that are available under any licence — rather than a cut of the till.

Licence

AGPL-3.0-only. The full text is in LICENSE; the reasoning, and the licences that were rejected, are in ADR 0005.

— Ben Koo

About

Self-hosted table-side ordering for restaurants. A guest opens the code printed on their table, sees the menu, and orders from the page. Staff sign in on their own page, read that restaurant's open orders, and clear each ticket as it is served. Row-level security scopes the menu and every order. No app to install, no third-party requests. AGPL-3.0.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages