Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

Projects · · 6 min read

Building NovaRestro: From Idea to Restaurant Management Platform

  • Case Study
  • Architecture
  • Django
  • React
  • Nepal

A restaurant looks simple from the dining table. Behind the counter it's a real-time system with money, inventory, people and hungry customers all moving at once. NovaRestro is my attempt to model that honestly.

NovaRestro is a restaurant management platform: point of sale, QR menus, kitchen order tickets, table reservations, split billing, inventory, and role-based access for more than 15 staff roles. It also supports offline ordering for waiters and real-time updates between the floor and the kitchen.

This post is the story of how it took shape, what I got wrong along the way, and the decisions I'd make again.

Starting from the workflow, not the features

The first version of the feature list was what you'd expect: menu, orders, billing, reports. It was also useless, because it described screens, not work.

What actually helped was walking through a single order end to end and writing down every handoff:

  1. A guest sits at a table, or scans a QR code to see the menu.
  2. A waiter takes the order, or the guest places it from the QR menu.
  3. The order splits by station: the kitchen gets the curry, the bar gets the drinks.
  4. Each station marks items as preparing and ready.
  5. The waiter serves, adds more items, maybe moves the table.
  6. The bill is generated, possibly split between four friends, and paid by cash, card or wallet.
  7. Inventory is reduced, and the day's numbers update for the manager.

Every arrow in that list became a requirement. Most bugs I've fixed since then live on those arrows, not inside the boxes.

The stack

NovaRestro uses the stack I wrote about in My Tech Stack in 2026: What I Use and Why:

  • Backend: Django 6 with Django REST Framework, PostgreSQL.
  • Frontend: React with TypeScript, Tailwind CSS v4 and TanStack Query.
  • Deployment: Nginx and Gunicorn on a VPS.

Django was a deliberate choice. A restaurant system is mostly business rules: who can void an item, when stock is deducted, how a split bill handles a shared appetizer. Django's ORM, transactions and admin made it faster to get those rules right than to build the scaffolding myself.

Orders are a state machine

My first data model treated an order as one row with a status column. That broke the moment a table ordered a second round while the first was still cooking.

The fix was to make the order line, not the order, the unit of work. Each line moves through its own states:

class OrderItem(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending"
        SENT = "sent"          # ticket printed or shown on kitchen screen
        PREPARING = "preparing"
        READY = "ready"
        SERVED = "served"
        VOIDED = "voided"

    order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
    menu_item = models.ForeignKey(MenuItem, on_delete=models.PROTECT)
    station = models.ForeignKey(Station, on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField()
    unit_price = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)

Two details here saved me later. unit_price is copied onto the line at order time, so changing the menu price tomorrow doesn't rewrite yesterday's bills. And station lives on the line, which is what lets one order fan out into separate kitchen and bar tickets.

Transitions go through a single service function that checks the current state and the user's role. No view updates status directly.

Kitchen order tickets

Kitchen tickets sound like a printing feature. They're actually a routing feature. When a waiter sends an order, the backend groups pending lines by station and creates one ticket per station. A kitchen screen subscribes to its station and shows tickets in order, with the time since each was sent.

Say a waiter adds a forgotten side dish five minutes later. That becomes a new small ticket for the kitchen, clearly marked as an addition, instead of reprinting the whole order and confusing the cooks.

Real-time without overbuilding

Waiters need to know when food is ready. The kitchen needs to know when an item is voided. The manager wants to see tables filling up.

I resisted building a complex event system. The backend publishes small events like order_item.ready scoped to a restaurant and station. The frontend listens and, instead of trusting the event payload, invalidates the related TanStack Query cache and refetches. That keeps the server as the single source of truth and means a missed event costs one stale screen for a few seconds, not corrupted data.

Treat real-time events as a hint to refetch, not as the data itself. It makes the whole system far more forgiving.

Split billing is harder than it looks

Splitting a bill evenly is arithmetic. Splitting it the way real groups do is a product problem. Some people pay for their own items, two people share a pizza, one person covers the drinks.

I ended up supporting three modes: equal split, split by item, and custom amounts. Under the hood, every split produces payment allocations that must sum exactly to the bill total, including tax and service charge. Rounding is handled once, on the last allocation, so paisa never go missing. Each allocation can be paid by a different method, which matters in Nepal where one friend pays cash and another pays with a wallet like eSewa or Khalti.

Offline ordering for waiters

Restaurant Wi-Fi is often the worst Wi-Fi in the building, and power or network drops still happen. Losing an order because the router restarted is not acceptable.

The waiter interface keeps a local queue. If a request fails, the order is stored on the device with a client-generated ID and shown as "waiting to sync". When the connection returns, the queue replays in order. The client ID makes the server endpoint idempotent, so a retry never creates a duplicate order.

  • Client-generated IDs: retries are safe, duplicates are rejected.
  • Visible sync state: the waiter always knows what has reached the kitchen.
  • Server wins on conflict: if an item was voided meanwhile, the device updates.

Roles: more than "admin" and "staff"

A restaurant has owners, managers, cashiers, waiters, captains, kitchen staff, bar staff, inventory keepers and more. NovaRestro supports more than 15 staff roles, and each needs a slightly different slice of the system. A waiter can add items but not void a paid bill. A cashier can take payments but not edit the menu.

Getting this right deserves its own post, and I'll write one on how the RBAC is designed. The short version: permissions are defined in code, roles are bundles of permissions, and every check happens on the server.

Inventory and the honest answer

Inventory is where I simplified the most. Full recipe-level costing, where every dish deducts grams of each ingredient, is powerful but heavy to set up for a small restaurant. NovaRestro supports linking menu items to stock items, deducting on order completion, and low-stock alerts. Restaurants that want deeper costing can map recipes in more detail; those that don't aren't forced to.

What I'd do differently

  • Model order lines first. I lost time migrating away from order-level status.
  • Write the permission matrix before the screens. Retrofitting role checks is painful.
  • Test on the cheapest device staff will actually use. It changed several layout decisions.
  • Design the offline state from day one. Bolting it on later touched more code than I expected.

Where I've landed

NovaRestro taught me that domain modelling is the real work. The UI, the API and the deployment are all much easier once the shape of an order, a ticket and a payment is right. If you're building something similar, spend your first week with a notebook and a real restaurant's workflow, not with a component library.