All posts
Fullstack

An order board that never refreshes

· 3 min read

How I built Qrder, a QR ordering platform for cafés, and why its kitchen screen subscribes to changes instead of polling for them.

A café owner doesn't want an app. They want the order on the kitchen screen before the customer has put their phone down, and most of how I built Qrder follows from that.

How ordering works#

A customer scans the QR code on their table, browses the menu in their own language, customises an item and pays by card or at the counter. There's nothing to download and no account to create just to order a coffee.

Staff see the orders arrive on a board, which is where the interesting decisions are.

Subscribing instead of polling#

The board is a Supabase Realtime subscription. Polling (asking the server every few seconds whether anything has changed) works, but it adds a delay between a paid order existing and staff seeing it, and almost every request comes back with nothing new.

Here's the subscription from the dashboard, slightly trimmed:

const channel = supabase.channel('dashboard-orders')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'orders', filter: `cafe_id=eq.${cafeId}` },
    (payload) => {
      fetchOrders()
      // New order notification
      if (payload.eventType === 'INSERT') {
        const newOrder = payload.new as Order
        if (!knownOrderIds.current.has(newOrder.id)) {
          knownOrderIds.current.add(newOrder.id)
          playSound()
          addToast({
            title: `New Order #${newOrder.order_number}`,
            body: `Table ${newOrder.table_number} · $${Number(newOrder.total).toFixed(2)}`,
            type: 'info',
          })
          // …and saved to the notifications table
        }
      }
    },
  )
  .subscribe()
return () => { getSupabaseClient().removeChannel(channel) }

Filtered by café#

The filter is what makes this multi-tenant. Changes are filtered by cafe_id on the server, so a café's board only hears about its own orders. The browser never receives another tenant's rows, even to discard them.

Refetching on every change#

The handler doesn't patch the list from the payload. Any insert, update or delete triggers a fresh fetchOrders(), so the board always matches the database. The payload is only read to decide whether to chime, and knownOrderIds stops an order chiming twice.

Marking an order as paid#

Checkout goes through Stripe. A customer can close the tab halfway through paying, so the redirect back to the site isn't the source of truth. The webhook marks the order paid instead. Here's the whole route:

export async function POST(request: NextRequest) {
  const body = await request.text()
  const sig = request.headers.get('stripe-signature')

  if (!sig) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
  }

  const stripe = getStripe()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object
    const orderId = session.metadata?.order_id

    if (orderId) {
      const supabase = await createServerClient()
      await supabase
        .from('orders')
        .update({ payment_status: 'paid' })
        .eq('id', orderId)
    }
  }

  return NextResponse.json({ received: true })
}

Reading the raw body#

The body is read with request.text(), not parsed as JSON. Stripe signs the exact bytes it sent, and if you parse them and serialise them again, the signature stops matching.

Handling retries#

Stripe retries webhooks, so the same event can arrive more than once. Setting payment_status to 'paid' a second time changes nothing, so a retry is harmless.

That update is also a change to the orders table, so the board refreshes by itself and staff see the order turn paid. I never had to wire the webhook to the board.

The AI photo studio#

Qrder also has an AI photo studio that generates food photography from a menu item's name and description. The model behind it will change, so only one folder knows which one it is. Every provider implements the same contract:

export interface ImageGenerationProvider {
  /** Stable identifier persisted on the generation row. */
  readonly id: string
  /** Model name persisted alongside, so old rows stay explainable. */
  readonly model: string
  /** False when the provider's credentials are missing from the environment. */
  isConfigured(): boolean
  generate(request: ImageGenerationRequest): Promise<GeneratedImageAsset[]>
}

One function picks the provider:

const FACTORIES: Record<ProviderId, () => ImageGenerationProvider> = {
  openai: createOpenAiProvider,
  gemini: createGeminiProvider,
  replicate: createReplicateProvider,
  mock: createMockProvider,
}

export function getFoodImageProvider(): ImageGenerationProvider {
  const requested = (process.env.AI_IMAGE_PROVIDER || '').trim().toLowerCase()

  if (requested) {
    const factory = FACTORIES[requested as ProviderId]
    if (!factory) {
      throw new ImageGenerationError(
        `Unknown AI_IMAGE_PROVIDER "${requested}". Expected one of: ${Object.keys(FACTORIES).join(', ')}.`,
        { provider: requested, status: 500 },
      )
    }
    return factory()
  }

  for (const id of ['openai', 'gemini', 'replicate'] as const) {
    const provider = FACTORIES[id]()
    if (provider.isConfigured()) return provider
  }

  return createMockProvider()
}

With no keys set, the last line falls back to the mock provider, so a fresh clone still has a working studio. Switching models is one new file under providers/ and one environment variable.

Providers return raw bytes, never a URL, because provider URLs expire and a menu photo shouldn't.

Every table needs its own code, and a café with thirty tables doesn't want to download thirty files. The dashboard generates them in the browser and hands back one ZIP:

const zip = new JSZip()

for (const table of tables) {
  const url = `${baseUrl}/cafe/${cafeSlug}/table/${table}`
  const dataUrl = await QRCode.toDataURL(url, {
    width: 400,
    margin: 2,
    color: { dark: '#061b0e', light: '#fcf9f4' },
    errorCorrectionLevel: 'H',
  })
  const base64 = dataUrl.split(',')[1]
  zip.file(`qrder-table-${table}.png`, base64, { base64: true })
}

const blob = await zip.generateAsync({ type: 'blob' })

The setting to notice is errorCorrectionLevel: 'H'. It's the highest level, and it keeps a code scannable with roughly 30% of it damaged, say by a coffee ring or a worn corner.

Everything else#

  • A cart persisted with Zustand, so a reload doesn't empty it
  • Four locales (English, French, Spanish and Arabic), with Arabic laid out right to left
  • Loyalty points, earned and redeemed
  • Per-tenant theming, with features gated by plan

Built with Next.js 16, Supabase, PostgreSQL, Stripe, Tailwind and Zustand.

What I'd change#

Refetching the full order list on every change is simple and always correct, and for one café it costs nothing. But the payload already has the changed row, so for a busy client with several locations I'd patch the list from it and only refetch after a reconnect.

NextEvery episode is a station on the dial