LumoDine: Architecture of a Multi-Tenant QR Menu SaaS

LumoDine is a QR menu platform for restaurants and cafés. A restaurant signs up, builds its menu in a dashboard, and gets a QR code to print on its tables. Guests scan it and land on the restaurant's own subdomain, where they can switch between languages and currencies. This post describes how the system is put together, and what I would change.

The services

api        Fastify + MongoDB   the only service that touches the database
qr-menu    Next.js             public menus on {alias}.lumodine.com
dashboard  Next.js             where restaurants manage menus, branches, themes
qr         Fastify             QR scan endpoint: log the scan, redirect to the menu
landing    Next.js             marketing site

Everything goes through the API over REST. The two Next.js apps never talk to MongoDB directly. They call the API through an internal URL when rendering on the server, and through the public URL from the browser. All services ship as Docker images and run together from one Compose file. Uploaded files go to S3 and are served through a CDN domain.

One API with several thin front-ends keeps the rules about who can see what in exactly one place. With a single database and a single writer, there is nothing to keep in sync.

A subdomain per restaurant

Each restaurant chooses an alias and gets alias.lumodine.com. On the menu app, a small Next.js proxy reads the alias from the Host header and rewrites the request to a route segment:

export async function proxy(request: NextRequest) {
  const url = request.nextUrl;
  const host = request.headers.get("host");

  if (host?.indexOf("localhost") !== -1) {
    return NextResponse.next();
  }

  // NEXT_PUBLIC_QR_MENU_URL = "https://{alias}.lumodine.com"
  const [scheme, domain] = process.env.NEXT_PUBLIC_QR_MENU_URL!.split("{alias}");
  const tenantAlias = host?.replaceAll(scheme, "")?.replaceAll(domain, "");

  if (tenantAlias) {
    return NextResponse.rewrite(
      new URL("/" + tenantAlias + url.pathname + url.search, request.url),
    );
  }

  return NextResponse.next();
}

After the rewrite, a request for pizzeria.lumodine.com/menu is handled by app/[tenantAlias]/menu, and the rest of the app sees the alias as an ordinary route parameter. Locally there is no subdomain, so localhost skips the rewrite and you use /alias/... paths directly.

Aliases have rules: a minimum length, a restricted character set, and a blocklist of names that must never become a tenant's subdomain, like api, dashboard or cdn. The blocklist is in configuration, so adding a reserved name doesn't need a deploy.

QR codes that survive a rename

The printed QR code is the one thing a restaurant can't easily change. So it doesn't contain the alias. It points to the qr service with the tenant's ID. That service looks up the current alias, records a scan event for analytics, and redirects to the menu.

A restaurant can rename itself as often as it likes, and every table card already printed keeps working. The codes are generated at error-correction level H, the highest, so a scratched or stained table card still scans.

Tenancy in a shared database

All tenants share the same collections. Every tenant-owned document carries a tenant reference. On the API side, one decorator loads the tenant for a request, by ID in the dashboard or by alias on the public menu. If the request is authenticated, it also requires the user to be a member of that tenant:

const { sub } = request?.user ?? {};
let query = {};
if (sub !== undefined) {
    query = { ...query, 'users.user': { $in: sub } };
}
if (tenantId !== undefined) {
    query = { ...query, _id: tenantId };
} else if (tenantAlias !== undefined) {
    query = { ...query, alias: tenantAlias };
}
let tenant = await Tenant.findOne(query);

Routes compose that check with authentication and a permission check, and all of a tenant's sub-resources are mounted under its ID:

fastify.put(
    '/:tenantId/theme/color',
    {
        preHandler: [
            fastify.authenticate,
            fastify.authorize(PERMISSIONS.TENANT_UPDATE_THEME_COLOR),
            fastify.checkTenantByParams,
        ],
    },
    updateTenantThemeColor
);

fastify.register(require('../item'), { prefix: '/:tenantId' });
fastify.register(require('../menu'), { prefix: '/:tenantId' });

Putting the tenant lookup in one decorator means a handler never has to load the tenant itself. It just filters by the tenant ID that was already checked.

One collection for the whole menu

The data model is the most interesting part. A menu has categories, sub-categories, products, product variants and tags. Instead of five collections, they all live in one items collection, using Mongoose discriminators with a kind field:

items (one collection, discriminatorKey: "kind")
  kind: category | sub-category | tag | product | product-variant
  tenant, translations[{ language, title, description }], sort, status
  product / product-variant: prices[{ currency, amount }]

item-relations
  { source: { item, kind }, target: { item, kind } }

Creating a nested menu from a template writes many items and relations at once. That runs in a MongoDB transaction, so a failure halfway doesn't leave half a menu behind. Note that transactions need MongoDB to run as a replica set, even with a single node.

Themes without a build step

Restaurants pick a colour scheme, a font and a header layout from fixed lists. The menu applies them as CSS classes and loads the matching theme and font stylesheets from the CDN. Adding a theme is a new stylesheet on the CDN, not a new release of the menu app.

What I would change

LumoDine is still young, and there are a few things I would fix before growing it:

None of these change the architecture. The service split, the subdomain routing, the ID-based QR codes and the item graph have held up well. The work left is the hardening that turns a product that works into one you can keep running.

← All posts