Back to Blog

Build a Restaurant Table Ordering System with QR Codes

QRWorks Team14 min read

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);

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

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.

Ready to get started?

Create your free account and start generating QR codes in minutes.