Skip to content

13 — Domains, Routing & Every Link

Status: ✅ Routing is built and tested end to end — subdomains, custom-domain resolution, path-based routes, the locale prefix, and every sidebar link. 🟡 Custom domains: the owner's setup (enter domain → DNS record → real DNS check) is built; the last hop, routing a verified domain to the menu, waits on the backend. Nothing is deployed yet.

🟢 বাংলা ভূমিকা: এই ডকুমেন্টে এক জায়গায় আছে — কোন ঠিকানা কোথায় যায়, src/proxy.ts প্রতিটি request-এ ঠিক কী করে, ৪৭টি route-এর পূর্ণ তালিকা, প্রতিটি লিংক ও বোতাম কোথায় নিয়ে যায়, লোকাল ডেভেলপমেন্ট ও Vercel-এ production সেটআপ, রেস্টুরেন্টের নিজস্ব ডোমেইন কীভাবে যুক্ত হয়, আর কিছু ভুল হলে কোথায় খুঁজবেন।


1. Three ways to reach a restaurant

A restaurant's screens can be reached three ways. All three end up at the same page.

ShapeExampleWhat happens
Subdomainswadh-ghar.restaurant-os.com/tableproxy.ts rewrites to the long form. The address bar keeps the clean path.
Custom domainorder.swadhghar.com/tableSame rewrite, matched on Tenant.customDomain.
Path-basedrestaurant-os.com/en/tenants/swadh-ghar/tableThis is the route. No rewrite.

The path-based form is not a leftover. It is the route the other two are rewritten to, so it has to exist. It is also how localhost works, how the docs link, how every test reaches the app, and the only form available on a *.vercel.app address (§6).

Guest scans QR ─► swadh-ghar.restaurant-os.com/table
                         │
                  src/proxy.ts  (runs on every request)
                         │  Host header → "swadh-ghar"
                         ▼
     rewrite ─► /en/tenants/swadh-ghar/table   ← the real Next.js route
                         │
                         ▼
     src/app/[locale]/tenants/[tenant]/table/page.tsx renders
     (the guest's address bar still shows swadh-ghar.restaurant-os.com/table)

🟢 বাংলা সারাংশ: একটা রেস্টুরেন্টে তিনভাবে পৌঁছানো যায় — subdomain, নিজস্ব ডোমেইন, আর path দিয়ে (/en/tenants/swadh-ghar/...)। প্রথম দুটোকে proxy ভেতরে ভেতরে তৃতীয়টায় rewrite করে, তাই অতিথির address bar-এ পরিষ্কার ঠিকানাই থাকে। Path-based রূপটা ভুল করে থেকে যায়নি — এটাই আসল route, আর localhost, টেস্ট ও vercel.app-এ এটাই একমাত্র উপায়।


2. What src/proxy.ts does, in order

The proxy runs on every request except /_next/*, /api/*, and anything with a dot in the path (files such as favicon.ico and sw.js). The matcher is /((?!_next|api|.*\..*).*).

2.1 Which restaurant is this host?

resolveTenantSlug(host) checks these in order and stops at the first match. It is exported and tested in src/proxy.test.ts.

#HostResult
1localhost, 127.0.0.1 (any port)Platform
2{label}.localhostTenant {label}, unless reserved
3restaurant-os.com, www.restaurant-os.comPlatform
4{label}.restaurant-os.comTenant {label}, unless reserved
5Anything elseLooked up as a custom domain. The owning tenant, or platform if none
  • Reserved labels (src/config/reserved-slugs.ts) are never treated as tenants: www app admin platform api assets static cdn mail smtp ftp support help docs status blog dashboard auth login signup billing dev staging test. If admin weren't reserved, admin.restaurant-os.com would belong to whoever registered that slug.
  • An unknown subdomain (nobody.restaurant-os.com) still resolves as a tenant slug, on purpose. The route then shows the restaurant not found page with a real 404. Falling through to the marketing site would show a stranger the wrong thing entirely.
  • Custom domains match through getTenantBySlug, which checks customDomain as well as slug. The name doesn't make that obvious. It is intended, and it is pinned in tests.
  • Hostnames are compared case-insensitively.
Flow diagram

2.2 Tenant traffic: rewrite

{any path on a tenant host}  ─►  /{locale}/tenants/{slug}{path}
/                            ─►  /{locale}/tenants/{slug}/table   (the root is the menu)

On a tenant host the language is not in the URL. The guest sees /table, not /en/table. The language comes from §2.4 and is saved in the NEXT_LOCALE cookie for a year.

A path that does start with a language is still understood (planTenantPath in src/proxy.ts), because the app builds most of its own links in the path-based shape that works on localhost:

/{locale}/tenants/{this slug}/…   ─► 307 ─► /…               (remembering {locale})
/{locale}/login, /forgot-password ─►  served as-is           (same origin as the staff screens)
/{locale}/…  anything else        ─►  /{locale}/tenants/{slug}/…

Before this, each of those was prefixed a second time — /en/tenants/x/en/tenants/x/dashboard — and 404'd: the signed-out redirect, every dashboard sidebar link, and the PIN pad's hand-off, but only on the restaurant's own domain, which no test visited. Another restaurant's path (/en/tenants/other/…) is never redirected to; it 404s inside this one.

2.3 Platform traffic: redirect

On the platform domain the language is in the URL, so it can be bookmarked and shared. A path without it is redirected, not rewritten:

restaurant-os.com/        ─► 307 ─► restaurant-os.com/en
restaurant-os.com/login   ─► 307 ─► restaurant-os.com/en/login

2.4 Which language?

  1. The NEXT_LOCALE cookie, set by the language switcher.
  2. Otherwise the first language in the browser's Accept-Language header, if it is en or bn.
  3. Otherwise en.

🟢 বাংলা সারাংশ: Proxy প্রতিটি request-এ host দেখে ঠিক করে এটা কোন রেস্টুরেন্টের — localhost/মূল ডোমেইন হলে প্ল্যাটফর্ম, x.localhost বা x.restaurant-os.com হলে রেস্টুরেন্ট x (সংরক্ষিত নাম বাদে), নইলে নিজস্ব ডোমেইন হিসেবে খোঁজে। রেস্টুরেন্টের ট্রাফিক rewrite হয় (ঠিকানায় ভাষা দেখায় না, / মানে মেনু); প্ল্যাটফর্মের ট্রাফিক redirect হয় /en বা /bn-এ। ভাষা ঠিক হয় cookie → ব্রাউজারের ভাষা → ইংরেজি, এই ক্রমে। অচেনা subdomain-এ ইচ্ছে করেই 404 দেখায়।


3. Every route (51)

🔒 means the page is behind SessionGuard. That guard redirects in the browser; it is not authentication (see CLAUDE.md). ⚑ means the page is shown only when the restaurant's plan includes that feature.

3.1 Platform — restaurant-os.com/{en|bn}/…

AreaPathNotes
Marketing/Home
/about · /contact
/legal/privacy · /legal/terms · /legal/cookiesTemplate text, and each page says so
/docs · /docs/{slug}This documentation, rendered
/design-systemEvery token and primitive on one page (noindex)
Account/login · /signup · /forgot-passwordPrototype sign-in, nothing is verified
/staff-loginPick a restaurant, then continue to its PIN screen
Platform admin 🔒/platform-adminNeeds role platform-admin
…/tenants · …/tenants/{slug} · …/subscriptions · …/support · …/users · …/feature-flags · …/roadmap · …/notifications · …/audit-log · …/settings

3.2 Restaurant — clean path on its own host, or /{en|bn}/tenants/{slug} + the same path

AreaClean pathNotes
Guest/Goes to /table
/table · /table?table={id}&token={token}Menu. The QR carries the table and token
/tablesPick a table, for a guest with no phone to scan with
/promo · /promo/{id}Campaign landing pages
/visitThe restaurant's own public page: what it is known for, the food, where to find it
/menu-boardThe menu for a wall TV; scrolls itself (noindex)
/table?dish={id} · /table?ref=1Opens a dish directly (link previews as that dish) · a friend's invite with the welcome code
/pickup-screenOrder-ready board for the counter TV
/staff-loginFour-digit PIN for shared devices
Shift screens 🔒/kitchen · /waiter · /rider
Dashboard 🔒/dashboardOverview
/dashboard/menu · /packages⚑ · /promos · /orders · /tables · /reservations⚑ · /pos · /inventory⚑ · /crm⚑ · /staff · /staff/{id} · /branches⚑ · /analytics · /insights⚑ · /settings

🟢 বাংলা সারাংশ: মোট ৫১টি route। প্ল্যাটফর্মের সব ঠিকানায় ভাষা থাকে (/en/...)। রেস্টুরেন্টের প্রতিটি পেজ নিজের ডোমেইনে পরিষ্কার ঠিকানায় (/table, /dashboard/menu) অথবা /en/tenants/{slug}/... দিয়ে খোলে। 🔒 চিহ্নিত পেজে সাইন-ইন লাগে (তবে এটা এখনো আসল নিরাপত্তা নয়), ⚑ চিহ্নিত পেজ প্ল্যানে থাকলে তবেই দেখায়।


4. Where every link goes

4.1 Marketing site

LinkGoes to
Navbar Log in/{locale}/login
Navbar Start free trial · hero CTA · CTA banner · pricing Get started/{locale}/signup
Pricing Contact sales (Enterprise)/{locale}/contact
Footer: Features · Pricing · How it worksOn-page anchors #features #pricing #how-it-works
Footer: About · Contact/about · /contact
Footer: Product docs/docs
Footer: Help center/contact, where the support address is
Footer: Privacy · Terms · Cookies/legal/privacy · /legal/terms · /legal/cookies

4.2 Signing in and out

FromGoes to
/login, owner email of a seeded restaurantThat restaurant's /dashboard
/login, any other email/platform-admin
/login?next={path}{path}, if it is a same-site path (open redirects are refused)
Staff PIN: chef or kitchen/kitchen
Staff PIN: waiter/waiter
Staff PIN: rider/rider
Staff PIN: cashier/dashboard/pos
Staff PIN: owner or manager/dashboard
Any 🔒 page while signed out/login?next={that page} — on a restaurant's own host too (§2.2)
/kitchen or /waiter while signed outThat restaurant's /staff-login (the PIN pad; riders are not on it, so /rider uses /login)
Platform admin, signed in as anyone else/login?next=…
Account menu → Sign out/login
/signup, after the last stepThe new restaurant's /dashboard

4.3 Inside a restaurant

LinkGoes to
Dashboard sidebar (15 entries)/dashboard/…, prefixed for the address shape in use (§7)
Sidebar View live menu/table, in a new tab
A locked feature → Back to dashboard/dashboard, prefixed the same way
Table QR code/table?table={id}&token={token} (a regenerated token invalidates old prints)
/tables → pick a table/table?table={id}
Promo QR code/promo/{id}
Tables board → Take an order/dashboard/pos?table={id}

4.4 Platform admin sidebar (11 entries)

Overview · Tenants · Subscriptions · Support · Users · Feature flags · Roadmap · Notifications · Audit log · Settings · Docs. All are absolute /{locale}/platform-admin/… paths, plus /{locale}/docs.

🟢 বাংলা সারাংশ: মার্কেটিং সাইটের Log in → /login, trial/Get started → /signup, Contact sales → /contact। লগইনের পর রোল অনুযায়ী যায় — মালিক dashboard-এ, শেফ kitchen-এ, ওয়েটার waiter-এ, রাইডার rider-এ, ক্যাশিয়ার POS-এ, প্ল্যাটফর্ম অ্যাডমিন platform-admin-এ। সাইন-ইন ছাড়া 🔒 পেজে গেলে /login?next=...-এ পাঠায়, আর লগইনের পর আগের পেজে ফিরিয়ে আনে। টেবিলের QR /table?table=...&token=...-এ যায়।


5. Local development

npm run dev
WantOpen
Marketing sitehttp://localhost:3000
A restaurant, path-basedhttp://localhost:3000/en/tenants/swadh-ghar/table
A restaurant, the way guests will see ithttp://swadh-ghar.localhost:3000
A restaurant dashboardhttp://swadh-ghar.localhost:3000/dashboard (sign in first)

Chromium and Firefox resolve *.localhost to 127.0.0.1 without any setup. If a browser doesn't, add a hosts-file entry, for example 127.0.0.1 swadh-ghar.localhost.

The seeded restaurants are swadh-ghar, sakura-sushi, bella-vista, spice-route, burger-bunker and the-old-bistro. Swadh Ghar's custom domain is order.swadhghar.com. That one can only be tested against the resolver (src/proxy.test.ts), because it can't be reached without DNS.

E2E runs against a production build on port 3100 (playwright.config.ts), so it never tests a dev server that happens to be running.

🟢 বাংলা সারাংশ: লোকালে npm run dev চালিয়ে দুইভাবে রেস্টুরেন্ট খোলা যায় — localhost:3000/en/tenants/swadh-ghar/table অথবা swadh-ghar.localhost:3000। দ্বিতীয়টাই অতিথিরা যেভাবে দেখবে। Chrome/Firefox-এ *.localhost আপনাআপনি কাজ করে; না করলে hosts ফাইলে একটা লাইন যোগ করুন।


6. Production on Vercel

Nothing is deployed yet. This is the setup 10 plans for. Check Vercel's current documentation when doing it, because their dashboard and requirements change.

StepWhatWhy
1Buy restaurant-os.com. Keep siteConfig.rootDomain in sync with itThe proxy recognises subdomains against this value
2Add restaurant-os.com and *.restaurant-os.com to the Vercel projectOne wildcard serves every restaurant. Vercel requires its own nameservers for wildcard domains
3Point cname.restaurant-os.com at Vercel's CNAME targetOwners' custom domains point at our name, so a later host change never means asking every restaurant to edit their DNS
4Set VERCEL_PROJECT_ID and VERCEL_API_TOKENThe Domains API needs them to attach custom domains (backend, not built)
5Connect the GitHub repoVercel deploys on merge. CI (.github/workflows/ci.yml) is only the gate, it doesn't deploy

6.1 *.vercel.app — what it gives you and what it doesn't

Every Vercel project gets a free restaurant-os.vercel.app address, and every PR gets a preview address. There are two things to know:

  • Tenant subdomains aren't available there. swadh-ghar.restaurant-os.vercel.app can't be had. On vercel.app, restaurants are reached path-based only: restaurant-os.vercel.app/en/tenants/swadh-ghar/table.
  • The proxy treats *.vercel.app as the platform, because it is neither the root domain, a subdomain of it, nor anyone's custom domain. The marketing site, sign-in and platform admin work normally, including on preview URLs.

🟢 বাংলা সারাংশ: এখনো কিছু deploy হয়নি। Vercel-এ লাগবে: ডোমেইন কেনা, মূল ডোমেইন ও *.restaurant-os.com (wildcard) যুক্ত করা, cname.restaurant-os.com-কে Vercel-এর দিকে point করা, আর Domains API-র জন্য দুটো environment variable। বিনামূল্যের *.vercel.app ঠিকানায় রেস্টুরেন্টের subdomain পাওয়া যায় না — সেখানে শুধু path দিয়ে (/en/tenants/swadh-ghar/...) খোলে।


7. Rules for anyone adding a link

  1. A link inside a restaurant must work on both address shapes. Don't hard-code /dashboard/menu, which 404s on the path-based form, and don't hard-code /en/tenants/swadh-ghar/dashboard/menu, which is wrong on a subdomain. Use the helper:
    import { tenantHref } from "@/lib/tenant-path";
    const pathname = usePathname();
    <Link href={tenantHref(pathname, "/dashboard/menu")} />
    
    tenantBasePath(pathname) returns "" on a subdomain and "/en/tenants/swadh-ghar" on the long form. Every dashboard sidebar link used to be hard-coded to the clean form. Every one of them 404'd on the path-based URL, and nothing noticed, because tests only ever typed addresses.
  2. A platform link includes the locale, as in `/${locale}/…`.
  3. A new top-level word that must never be a restaurant goes in RESERVED_SLUGS.
  4. Tests: e2e/routing.spec.ts walks every link each sidebar actually renders, so a new sidebar entry is covered automatically. Add a page that nothing links to (a QR target, a deep link) to its DIRECT_ROUTES list.

🟢 বাংলা সারাংশ: রেস্টুরেন্টের ভেতরের যেকোনো লিংক tenantHref() দিয়ে বানাতে হবে, যাতে subdomain আর path-based দুই ঠিকানাতেই কাজ করে — হাতে লিখে দিলে একটায় 404 হবে (ঠিক এটাই আগে sidebar-এ হয়েছিল)। প্ল্যাটফর্মের লিংকে ভাষা থাকবে। নতুন সংরক্ষিত শব্দ RESERVED_SLUGS-এ যাবে। নতুন পেজ কোনো sidebar থেকে লিংক না হলে টেস্টের DIRECT_ROUTES-এ যোগ করুন।


8. A restaurant on its own domain

StepWhoBuilt?
Enter order.swadhghar.com in Settings → DomainOwner✅ Normalised from whatever was pasted. Refused: our own domain, a domain another restaurant holds, and a root domain (DNS forbids a CNAME there; .com.bd roots are recognised too)
Add CNAME order → cname.restaurant-os.com at the registrarOwner✅ The exact record is shown, and each field can be copied
Check DNSOwner✅ A real DNS-over-HTTPS lookup, so "verified" means the record exists. A failed lookup reads as "couldn't reach the checker", never as "no record"
Attach the domain to Vercel and issue SSLPlatform❌ Backend: Vercel Domains API, attachCustomDomain in 10
Save customDomain on the tenant recordPlatform❌ Backend: database
Route the domain to the menuProxy✅ Already works for any tenant record carrying customDomain, and tested

Why a verified domain doesn't reach the menu yet: the proxy runs on the server and reads the tenant record. It can't see anything the owner saved in their browser. The settings card says this in its first line.

🟢 বাংলা সারাংশ: মালিক Settings-এ ডোমেইন দেন, registrar-এ একটা CNAME রেকর্ড যোগ করেন, তারপর Check DNS চাপেন — এই তিনটাই তৈরি, আর যাচাইটা সত্যিকারের। বাকি দুটো backend-এর কাজ: Vercel-এ ডোমেইন যুক্ত করে SSL নেওয়া, আর ডাটাবেসে সংরক্ষণ। Proxy-র দিকটা ইতিমধ্যে কাজ করে — ডাটাবেসে customDomain থাকলেই ঠিক রেস্টুরেন্টে পাঠাবে।


9. When something goes wrong

SymptomLikely causeLook at
Clicking a dashboard link gives "We couldn't find that page"A tenant link was hard-coded instead of built with tenantHref§7, src/lib/tenant-path.ts
A sidebar item never shows as the current pageSame cause. The active check compares against the prefixed pathsrc/components/dashboard/sidebar.tsx
x.restaurant-os.com shows the marketing sitex is in RESERVED_SLUGS, or rootDomain doesn't match the real domainsrc/config/reserved-slugs.ts, src/config/site.ts
x.restaurant-os.com shows restaurant not foundNo tenant has slug x. This is intendedsrc/app/[locale]/tenants/not-found.tsx
Subdomains don't resolve at all in productionThe wildcard domain isn't added, or DNS isn't on Vercel's nameservers§6
Custom domain shows "DNS verified" but guests can't reach itExpected in this phase: routing needs the backend§8
A page opens in the wrong languageThe NEXT_LOCALE cookie wins over the browser language§2.4
Signed in, but sent back to /loginThe session is in localStorage for this browser only. A different browser or a private window has nonesrc/hooks/use-session.ts
swadh-ghar.localhost doesn't openThe browser doesn't resolve *.localhost§5, hosts file

🟢 বাংলা সারাংশ: সাধারণ সমস্যা ও কোথায় দেখবেন — dashboard লিংকে 404 হলে কেউ tenantHref ছাড়া লিংক লিখেছে; subdomain-এ মার্কেটিং সাইট দেখালে নামটা সংরক্ষিত বা rootDomain মেলেনি; অচেনা subdomain-এ "not found" ইচ্ছাকৃত; নিজস্ব ডোমেইন verified হলেও অতিথি পৌঁছাতে না পারা এই পর্যায়ে স্বাভাবিক; ভুল ভাষা দেখালে NEXT_LOCALE cookie দেখুন।