Build a Restaurant Table Ordering System with QR Codes
Every table in your restaurant is a data point. Who sat there? What did they order? How long did they stay?
A QR code on each table connects customers to your ordering system while capturing engagement data you'd never get otherwise.
In this tutorial, you'll build a table ordering system that:
- Generates unique QR codes for each table
- Displays your menu when customers scan
- Captures orders linked to specific tables
- Tracks engagement: scan times, popular items, table turnover
System architecture
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Table QR │────▶│ QRWorks │────▶│ Your App │
│ (scan) │ │ Redirect │ │ Menu/Order │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ ▼
│ │ ┌─────────────┐
│ │ │ Kitchen │
│ │ │ Display │
│ │ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Analytics │◀────│ Scan Data │ │ Orders │
│ Dashboard │ │ (who/when) │ │ Database │
└─────────────┘ └─────────────┘ └─────────────┘
Customer scans table QR → sees menu → places order → kitchen gets ticket → analytics tracks everything.
Database schema
-- tables
CREATE TABLE restaurant_tables (
id UUID PRIMARY KEY,
table_number INTEGER NOT NULL,
section VARCHAR(50),
capacity INTEGER,
qr_analytics_id VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
-- menu items
CREATE TABLE menu_items (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
category VARCHAR(100),
image_url VARCHAR(500),
available BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- orders
CREATE TABLE orders (
id UUID PRIMARY KEY,
table_id UUID REFERENCES restaurant_tables(id),
session_id VARCHAR(100), -- links scans in same visit
status VARCHAR(50) DEFAULT 'pending',
total DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
-- order items
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
menu_item_id UUID REFERENCES menu_items(id),
quantity INTEGER DEFAULT 1,
special_instructions TEXT,
price DECIMAL(10,2)
);
-- table sessions (track visits)
CREATE TABLE table_sessions (
id UUID PRIMARY KEY,
table_id UUID REFERENCES restaurant_tables(id),
started_at TIMESTAMP DEFAULT NOW(),
ended_at TIMESTAMP,
total_scans INTEGER DEFAULT 1,
total_orders INTEGER DEFAULT 0,
total_revenue DECIMAL(10,2) DEFAULT 0
);
Generate table QR codes
Set up each table with a unique QR code:
// tables.js
import fetch from 'node-fetch';
const API_KEY = process.env.QRWORKS_API_KEY;
const BASE_URL = process.env.QRWORKS_BASE_URL;
async function createTableQR(tableNumber, section) {
// URL includes table identifier
const menuUrl = `https://order.yourrestaurant.com/table/${tableNumber}`;
const response = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
redirect_url: menuUrl,
metadata: {
table_number: tableNumber,
section: section,
type: 'restaurant_table'
}
})
});
const data = await response.json();
// Save to database
await db.query(`
INSERT INTO restaurant_tables
(id, table_number, section, qr_analytics_id)
VALUES ($1, $2, $3, $4)
`, [generateId(), tableNumber, section, data.analytics_id]);
return {
tableNumber,
qrCodeUrl: data.qr_code_url,
shortUrl: data.short_url,
analyticsId: data.analytics_id
};
}
// Set up all tables
async function setupRestaurant(tableConfig) {
const tables = [];
for (const config of tableConfig) {
const table = await createTableQR(config.number, config.section);
tables.push(table);
console.log(`Created QR for table ${config.number}`);
}
return tables;
}
// Usage
const config = [
{ number: 1, section: 'patio' },
{ number: 2, section: 'patio' },
{ number: 3, section: 'main' },
{ number: 4, section: 'main' },
{ number: 5, section: 'bar' },
// ... more tables
];
await setupRestaurant(config);
Menu display endpoint
When a customer scans, show them the menu:
// server.js
import express from 'express';
const app = express();
app.use(express.json());
app.get('/table/:tableNumber', async (req, res) => {
const { tableNumber } = req.params;
// Get table info
const table = await db.query(
'SELECT * FROM restaurant_tables WHERE table_number = $1',
[tableNumber]
).then(r => r.rows[0]);
if (!table) {
return res.status(404).send('Table not found');
}
// Create or continue session
const sessionId = req.cookies.sessionId || generateSessionId();
// Check for existing session
let session = await db.query(
`SELECT * FROM table_sessions
WHERE table_id = $1
AND ended_at IS NULL
AND started_at > NOW() - INTERVAL '4 hours'`,
[table.id]
).then(r => r.rows[0]);
if (!session) {
// Start new session
session = await db.query(`
INSERT INTO table_sessions (id, table_id)
VALUES ($1, $2)
RETURNING *
`, [generateId(), table.id]).then(r => r.rows[0]);
} else {
// Update scan count
await db.query(
'UPDATE table_sessions SET total_scans = total_scans + 1 WHERE id = $1',
[session.id]
);
}
// Get menu
const menuItems = await db.query(`
SELECT * FROM menu_items
WHERE available = true
ORDER BY category, name
`).then(r => r.rows);
// Group by category
const menuByCategory = groupBy(menuItems, 'category');
// Set session cookie
res.cookie('sessionId', sessionId, {
maxAge: 4 * 60 * 60 * 1000, // 4 hours
httpOnly: true
});
res.json({
table: {
number: table.table_number,
section: table.section
},
session: {
id: session.id,
startedAt: session.started_at
},
menu: menuByCategory
});
});
function groupBy(arr, key) {
return arr.reduce((groups, item) => {
const group = item[key] || 'Other';
if (!groups[group]) groups[group] = [];
groups[group].push(item);
return groups;
}, {});
}
Order submission
Handle order placement:
app.post('/table/:tableNumber/order', async (req, res) => {
const { tableNumber } = req.params;
const { items, sessionId } = req.body;
// Get table
const table = await db.query(
'SELECT * FROM restaurant_tables WHERE table_number = $1',
[tableNumber]
).then(r => r.rows[0]);
// Get session
const session = await db.query(
'SELECT * FROM table_sessions WHERE id = $1',
[sessionId]
).then(r => r.rows[0]);
// Calculate total
let total = 0;
const orderItems = [];
for (const item of items) {
const menuItem = await db.query(
'SELECT * FROM menu_items WHERE id = $1',
[item.menuItemId]
).then(r => r.rows[0]);
if (!menuItem || !menuItem.available) {
return res.status(400).json({
error: `Item ${item.menuItemId} is not available`
});
}
const itemTotal = menuItem.price * item.quantity;
total += itemTotal;
orderItems.push({
menuItemId: menuItem.id,
name: menuItem.name,
quantity: item.quantity,
price: menuItem.price,
specialInstructions: item.specialInstructions
});
}
// Create order
const order = await db.query(`
INSERT INTO orders (id, table_id, session_id, total)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [generateId(), table.id, session.id, total])
.then(r => r.rows[0]);
// Insert order items
for (const item of orderItems) {
await db.query(`
INSERT INTO order_items
(id, order_id, menu_item_id, quantity, special_instructions, price)
VALUES ($1, $2, $3, $4, $5, $6)
`, [
generateId(),
order.id,
item.menuItemId,
item.quantity,
item.specialInstructions,
item.price
]);
}
// Update session stats
await db.query(`
UPDATE table_sessions
SET total_orders = total_orders + 1,
total_revenue = total_revenue + $1
WHERE id = $2
`, [total, session.id]);
// Notify kitchen
await notifyKitchen({
orderId: order.id,
tableNumber,
items: orderItems,
total
});
res.json({
orderId: order.id,
status: 'pending',
items: orderItems,
total,
message: 'Order received! Your food is being prepared.'
});
});
async function notifyKitchen(order) {
// Send to kitchen display system
// This could be WebSocket, push notification, or printer
console.log(`
====== NEW ORDER ======
Table: ${order.tableNumber}
Order: ${order.orderId}
───────────────────────
${order.items.map(i =>
`${i.quantity}x ${i.name}${i.specialInstructions ? ` (${i.specialInstructions})` : ''}`
).join('\n ')}
───────────────────────
Total: $${order.total.toFixed(2)}
=======================
`);
}
Kitchen display system
Real-time order tracking:
// Kitchen display receives orders via WebSocket
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Broadcast new orders to kitchen displays
function broadcastToKitchen(order) {
wss.clients.forEach(client => {
client.send(JSON.stringify({
type: 'new_order',
order
}));
});
}
// Order status updates
app.post('/orders/:orderId/status', async (req, res) => {
const { orderId } = req.params;
const { status } = req.body; // preparing, ready, served, completed
await db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
[status, orderId]
);
if (status === 'completed') {
await db.query(
'UPDATE orders SET completed_at = NOW() WHERE id = $1',
[orderId]
);
}
// Broadcast status change
wss.clients.forEach(client => {
client.send(JSON.stringify({
type: 'status_update',
orderId,
status
}));
});
res.json({ success: true });
});
Scan analytics integration
Pull engagement data from QRWorks:
async function getTableAnalytics(tableNumber, days = 7) {
const table = await db.query(
'SELECT * FROM restaurant_tables WHERE table_number = $1',
[tableNumber]
).then(r => r.rows[0]);
const response = await fetch(
`${BASE_URL}/v1/analytics/${table.qr_analytics_id}`,
{ headers: { 'X-API-Key': API_KEY } }
);
const analytics = await response.json();
// Filter to time period
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const recentScans = analytics.scans.filter(
s => new Date(s.scanned_at) > cutoff
);
// Analyze patterns
const byHour = {};
const byDevice = { ios: 0, android: 0, other: 0 };
for (const scan of recentScans) {
// Hour breakdown
const hour = new Date(scan.scanned_at).getHours();
byHour[hour] = (byHour[hour] || 0) + 1;
// Device breakdown
const device = scan.device_type.toLowerCase();
if (device.includes('ios')) byDevice.ios++;
else if (device.includes('android')) byDevice.android++;
else byDevice.other++;
}
return {
tableNumber,
totalScans: recentScans.length,
avgScansPerDay: (recentScans.length / days).toFixed(1),
peakHours: Object.entries(byHour)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([hour, count]) => ({ hour: parseInt(hour), count })),
deviceBreakdown: byDevice
};
}
Restaurant dashboard
Aggregate data across all tables:
async function getRestaurantDashboard() {
// Today's stats
const todayStats = await db.query(`
SELECT
COUNT(DISTINCT ts.id) as active_sessions,
COUNT(o.id) as total_orders,
COALESCE(SUM(o.total), 0) as revenue
FROM table_sessions ts
LEFT JOIN orders o ON ts.id = o.session_id
WHERE DATE(ts.started_at) = CURRENT_DATE
`).then(r => r.rows[0]);
// Top items today
const topItems = await db.query(`
SELECT
mi.name,
SUM(oi.quantity) as quantity,
SUM(oi.price * oi.quantity) as revenue
FROM order_items oi
JOIN menu_items mi ON oi.menu_item_id = mi.id
JOIN orders o ON oi.order_id = o.id
WHERE DATE(o.created_at) = CURRENT_DATE
GROUP BY mi.id, mi.name
ORDER BY quantity DESC
LIMIT 10
`).then(r => r.rows);
// Table utilization
const tableUtilization = await db.query(`
SELECT
rt.table_number,
rt.section,
COUNT(ts.id) as sessions_today,
COALESCE(SUM(ts.total_revenue), 0) as revenue_today
FROM restaurant_tables rt
LEFT JOIN table_sessions ts ON rt.id = ts.table_id
AND DATE(ts.started_at) = CURRENT_DATE
GROUP BY rt.id, rt.table_number, rt.section
ORDER BY revenue_today DESC
`).then(r => r.rows);
// Average order value
const avgOrderValue = await db.query(`
SELECT AVG(total) as avg_order
FROM orders
WHERE DATE(created_at) = CURRENT_DATE
`).then(r => r.rows[0]);
return {
today: {
sessions: todayStats.active_sessions,
orders: todayStats.total_orders,
revenue: parseFloat(todayStats.revenue || 0),
avgOrderValue: parseFloat(avgOrderValue.avg_order || 0)
},
topItems,
tableUtilization
};
}
Output:
Restaurant Dashboard - Today
────────────────────────────
Sessions: 47 Orders: 89 Revenue: $2,345.67
Avg Order: $26.36
Top Items:
1. Margherita Pizza (23 sold) $414
2. Caesar Salad (18 sold) $234
3. Craft Burger (15 sold) $270
4. Fish Tacos (12 sold) $192
Table Performance:
Table │ Section │ Sessions │ Revenue
──────┼─────────┼──────────┼────────
12 │ patio │ 8 │ $312
3 │ main │ 7 │ $287
7 │ main │ 6 │ $245
Peak hour analysis
Know when to staff up:
async function getPeakHourAnalysis(days = 30) {
// Combine scan data with order data
const tables = await db.query('SELECT * FROM restaurant_tables');
const hourlyScans = {};
// Get scan data from QRWorks
for (const table of tables.rows) {
const analytics = await fetch(
`${BASE_URL}/v1/analytics/${table.qr_analytics_id}`,
{ headers: { 'X-API-Key': API_KEY } }
).then(r => r.json());
for (const scan of analytics.scans) {
const hour = new Date(scan.scanned_at).getHours();
const day = new Date(scan.scanned_at).getDay(); // 0-6
const key = `${day}-${hour}`;
if (!hourlyScans[key]) {
hourlyScans[key] = { scans: 0, dayName: getDayName(day), hour };
}
hourlyScans[key].scans++;
}
}
// Get order data
const ordersByHour = await db.query(`
SELECT
EXTRACT(DOW FROM created_at) as day,
EXTRACT(HOUR FROM created_at) as hour,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
WHERE created_at > NOW() - INTERVAL '${days} days'
GROUP BY EXTRACT(DOW FROM created_at), EXTRACT(HOUR FROM created_at)
`).then(r => r.rows);
// Combine data
const combined = {};
for (const row of ordersByHour) {
const key = `${row.day}-${row.hour}`;
combined[key] = {
day: getDayName(row.day),
hour: parseInt(row.hour),
scans: hourlyScans[key]?.scans || 0,
orders: parseInt(row.orders),
revenue: parseFloat(row.revenue)
};
}
// Find peaks
const sorted = Object.values(combined)
.sort((a, b) => b.orders - a.orders);
return {
peakTimes: sorted.slice(0, 10),
slowestTimes: sorted.slice(-5).reverse()
};
}
function getDayName(day) {
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][day];
}
Output:
Peak Hours Analysis (Last 30 Days)
──────────────────────────────────
Busiest:
1. Sat 7PM │ 45 orders │ $1,234 revenue
2. Fri 8PM │ 42 orders │ $1,156 revenue
3. Sun 6PM │ 38 orders │ $1,023 revenue
Slowest:
1. Tue 3PM │ 2 orders │ $45 revenue
2. Mon 2PM │ 3 orders │ $67 revenue
3. Wed 3PM │ 4 orders │ $89 revenue
Menu item performance
Track what sells and what doesn't:
async function getMenuPerformance(days = 30) {
const items = await db.query(`
SELECT
mi.id,
mi.name,
mi.category,
mi.price,
COUNT(oi.id) as times_ordered,
SUM(oi.quantity) as total_quantity,
SUM(oi.price * oi.quantity) as total_revenue,
AVG(oi.quantity) as avg_quantity_per_order
FROM menu_items mi
LEFT JOIN order_items oi ON mi.id = oi.menu_item_id
LEFT JOIN orders o ON oi.order_id = o.id
AND o.created_at > NOW() - INTERVAL '${days} days'
GROUP BY mi.id, mi.name, mi.category, mi.price
ORDER BY total_revenue DESC NULLS LAST
`).then(r => r.rows);
// Categorize performance
const totalRevenue = items.reduce((sum, i) => sum + parseFloat(i.total_revenue || 0), 0);
return items.map(item => ({
...item,
revenueShare: totalRevenue > 0
? ((item.total_revenue / totalRevenue) * 100).toFixed(1) + '%'
: '0%',
performance: categorizePerformance(item, items)
}));
}
function categorizePerformance(item, allItems) {
const avgRevenue = allItems.reduce((sum, i) =>
sum + parseFloat(i.total_revenue || 0), 0
) / allItems.length;
const itemRevenue = parseFloat(item.total_revenue || 0);
if (itemRevenue > avgRevenue * 1.5) return 'star';
if (itemRevenue > avgRevenue) return 'good';
if (itemRevenue > avgRevenue * 0.5) return 'average';
if (itemRevenue > 0) return 'underperforming';
return 'not_selling';
}
Mobile-first menu UI
The customer experience:
app.get('/table/:tableNumber', async (req, res) => {
// ... get table and menu data ...
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Order - Table ${table.table_number}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, sans-serif;
background: #f5f5f5;
padding-bottom: 80px;
}
header {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 20px;
text-align: center;
}
.category { margin: 20px 0; }
.category-title {
padding: 10px 20px;
font-weight: bold;
background: #e0e0e0;
}
.menu-item {
display: flex;
padding: 15px 20px;
background: white;
border-bottom: 1px solid #eee;
}
.item-info { flex: 1; }
.item-name { font-weight: 600; }
.item-desc { font-size: 14px; color: #666; margin-top: 4px; }
.item-price { font-weight: bold; color: #667eea; }
.add-btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
}
.cart-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #667eea;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<header>
<h1>Your Restaurant</h1>
<p>Table ${table.table_number}</p>
</header>
${Object.entries(menuByCategory).map(([category, items]) => `
<div class="category">
<div class="category-title">${category}</div>
${items.map(item => `
<div class="menu-item">
<div class="item-info">
<div class="item-name">${item.name}</div>
<div class="item-desc">${item.description || ''}</div>
<div class="item-price">$${item.price.toFixed(2)}</div>
</div>
<button class="add-btn" onclick="addToCart('${item.id}')">
Add
</button>
</div>
`).join('')}
</div>
`).join('')}
<div class="cart-bar" id="cart-bar" style="display: none;">
<span id="cart-count">0 items</span>
<button onclick="viewCart()">View Cart</button>
</div>
<script>
const cart = [];
function addToCart(itemId) {
cart.push(itemId);
updateCartBar();
}
function updateCartBar() {
const bar = document.getElementById('cart-bar');
const count = document.getElementById('cart-count');
bar.style.display = cart.length > 0 ? 'flex' : 'none';
count.textContent = cart.length + ' item' + (cart.length > 1 ? 's' : '');
}
function viewCart() {
window.location.href = '/table/${table.table_number}/cart';
}
</script>
</body>
</html>
`);
});
End session tracking
When customers pay and leave:
app.post('/table/:tableNumber/close', async (req, res) => {
const { tableNumber } = req.params;
const { sessionId } = req.body;
// End the session
await db.query(`
UPDATE table_sessions
SET ended_at = NOW()
WHERE id = $1
`, [sessionId]);
// Calculate session metrics
const session = await db.query(`
SELECT
ts.*,
EXTRACT(EPOCH FROM (NOW() - ts.started_at)) / 60 as duration_minutes
FROM table_sessions ts
WHERE ts.id = $1
`, [sessionId]).then(r => r.rows[0]);
res.json({
sessionId,
tableNumber,
duration: Math.round(session.duration_minutes) + ' minutes',
totalScans: session.total_scans,
totalOrders: session.total_orders,
totalRevenue: session.total_revenue
});
});
Summary
You now have a restaurant table ordering system that:
- Generates unique QR codes for each table
- Displays a mobile-friendly menu on scan
- Captures orders and routes them to the kitchen
- Tracks customer engagement via scan analytics
- Provides dashboard insights on peak hours and menu performance
- Measures table turnover and revenue per table
The key insight: every table scan tells you something. Are customers scanning once and ordering, or scanning multiple times (confused UI)? Which tables generate the most revenue? What time should you staff up?
Ready to modernize your restaurant? Create your free account and generate table QR codes in minutes.