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 siteEverything 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 } }- Shared fields stay shared. Translations, sort order and status work the same way for a category and a product, and are written once.
- Translations are data, not columns. Each item has a list of translations, one per language the restaurant enabled. Adding a language doesn't change the schema. Right-to-left languages are marked on the language itself, and the menu sets the page direction accordingly.
- Prices per currency. A product has a list of prices, one per currency. A restaurant near a border, or one with many tourists, can show both local and euro prices without any conversion logic.
- Structure is a graph. Parent and child links aren't stored on the items. They live in a separate relations collection of source and target edges. A product can appear in two categories, and a tag can attach to any number of products, without copying anything.
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:
- Cache the public menu. Menus change a few times a week but are read on every scan. Today every scan goes to the API. A short CDN cache, invalidated when the restaurant saves, would remove most of that load.
- Use schemas on every route. Fastify has request validation built in, and declaring a schema per route is the cheapest safety improvement available.
- Put the tenant on every document. The relations collection doesn't carry a tenant field, so it can't be filtered or indexed per tenant like the rest. In a shared-collection design, every collection should have one.
- Process images on upload. Resize images when they're uploaded instead of relying on the front-end.
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.