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.
| Shape | Example | What happens |
|---|---|---|
| Subdomain | swadh-ghar.restaurant-os.com/table | proxy.ts rewrites to the long form. The address bar keeps the clean path. |
| Custom domain | order.swadhghar.com/table | Same rewrite, matched on Tenant.customDomain. |
| Path-based | restaurant-os.com/en/tenants/swadh-ghar/table | This 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.
| # | Host | Result |
|---|---|---|
| 1 | localhost, 127.0.0.1 (any port) | Platform |
| 2 | {label}.localhost | Tenant {label}, unless reserved |
| 3 | restaurant-os.com, www.restaurant-os.com | Platform |
| 4 | {label}.restaurant-os.com | Tenant {label}, unless reserved |
| 5 | Anything else | Looked 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. Ifadminweren't reserved,admin.restaurant-os.comwould 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 checkscustomDomainas well asslug. The name doesn't make that obvious. It is intended, and it is pinned in tests. - Hostnames are compared case-insensitively.
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?
- The
NEXT_LOCALEcookie, set by the language switcher. - Otherwise the first language in the browser's
Accept-Languageheader, if it isenorbn. - 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}/…
| Area | Path | Notes |
|---|---|---|
| Marketing | / | Home |
/about · /contact | ||
/legal/privacy · /legal/terms · /legal/cookies | Template text, and each page says so | |
/docs · /docs/{slug} | This documentation, rendered | |
/design-system | Every token and primitive on one page (noindex) | |
| Account | /login · /signup · /forgot-password | Prototype sign-in, nothing is verified |
/staff-login | Pick a restaurant, then continue to its PIN screen | |
| Platform admin 🔒 | /platform-admin | Needs 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
| Area | Clean path | Notes |
|---|---|---|
| Guest | / | Goes to /table |
/table · /table?table={id}&token={token} | Menu. The QR carries the table and token | |
/tables | Pick a table, for a guest with no phone to scan with | |
/promo · /promo/{id} | Campaign landing pages | |
/visit | The restaurant's own public page: what it is known for, the food, where to find it | |
/menu-board | The menu for a wall TV; scrolls itself (noindex) | |
/table?dish={id} · /table?ref=1 | Opens a dish directly (link previews as that dish) · a friend's invite with the welcome code | |
/pickup-screen | Order-ready board for the counter TV | |
/staff-login | Four-digit PIN for shared devices | |
| Shift screens 🔒 | /kitchen · /waiter · /rider | |
| Dashboard 🔒 | /dashboard | Overview |
/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
| Link | Goes 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 works | On-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
| From | Goes to |
|---|---|
/login, owner email of a seeded restaurant | That 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 out | That 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 step | The new restaurant's /dashboard |
4.3 Inside a restaurant
| Link | Goes 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
| Want | Open |
|---|---|
| Marketing site | http://localhost:3000 |
| A restaurant, path-based | http://localhost:3000/en/tenants/swadh-ghar/table |
| A restaurant, the way guests will see it | http://swadh-ghar.localhost:3000 |
| A restaurant dashboard | http://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.
| Step | What | Why |
|---|---|---|
| 1 | Buy restaurant-os.com. Keep siteConfig.rootDomain in sync with it | The proxy recognises subdomains against this value |
| 2 | Add restaurant-os.com and *.restaurant-os.com to the Vercel project | One wildcard serves every restaurant. Vercel requires its own nameservers for wildcard domains |
| 3 | Point cname.restaurant-os.com at Vercel's CNAME target | Owners' custom domains point at our name, so a later host change never means asking every restaurant to edit their DNS |
| 4 | Set VERCEL_PROJECT_ID and VERCEL_API_TOKEN | The Domains API needs them to attach custom domains (backend, not built) |
| 5 | Connect the GitHub repo | Vercel 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.appcan't be had. Onvercel.app, restaurants are reached path-based only:restaurant-os.vercel.app/en/tenants/swadh-ghar/table. - The proxy treats
*.vercel.appas 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
- 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. - A platform link includes the locale, as in
`/${locale}/…`. - A new top-level word that must never be a restaurant goes in
RESERVED_SLUGS. - Tests:
e2e/routing.spec.tswalks 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 itsDIRECT_ROUTESlist.
🟢 বাংলা সারাংশ: রেস্টুরেন্টের ভেতরের যেকোনো লিংক tenantHref() দিয়ে বানাতে হবে, যাতে subdomain আর path-based দুই ঠিকানাতেই কাজ করে — হাতে লিখে দিলে একটায় 404 হবে (ঠিক এটাই আগে sidebar-এ হয়েছিল)। প্ল্যাটফর্মের লিংকে ভাষা থাকবে। নতুন সংরক্ষিত শব্দ RESERVED_SLUGS-এ যাবে। নতুন পেজ কোনো sidebar থেকে লিংক না হলে টেস্টের DIRECT_ROUTES-এ যোগ করুন।
8. A restaurant on its own domain
| Step | Who | Built? |
|---|---|---|
Enter order.swadhghar.com in Settings → Domain | Owner | ✅ 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 registrar | Owner | ✅ The exact record is shown, and each field can be copied |
| Check DNS | Owner | ✅ 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 SSL | Platform | ❌ Backend: Vercel Domains API, attachCustomDomain in 10 |
Save customDomain on the tenant record | Platform | ❌ Backend: database |
| Route the domain to the menu | Proxy | ✅ 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
| Symptom | Likely cause | Look 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 page | Same cause. The active check compares against the prefixed path | src/components/dashboard/sidebar.tsx |
x.restaurant-os.com shows the marketing site | x is in RESERVED_SLUGS, or rootDomain doesn't match the real domain | src/config/reserved-slugs.ts, src/config/site.ts |
x.restaurant-os.com shows restaurant not found | No tenant has slug x. This is intended | src/app/[locale]/tenants/not-found.tsx |
| Subdomains don't resolve at all in production | The wildcard domain isn't added, or DNS isn't on Vercel's nameservers | §6 |
| Custom domain shows "DNS verified" but guests can't reach it | Expected in this phase: routing needs the backend | §8 |
| A page opens in the wrong language | The NEXT_LOCALE cookie wins over the browser language | §2.4 |
Signed in, but sent back to /login | The session is in localStorage for this browser only. A different browser or a private window has none | src/hooks/use-session.ts |
swadh-ghar.localhost doesn't open | The browser doesn't resolve *.localhost | §5, hosts file |
🟢 বাংলা সারাংশ: সাধারণ সমস্যা ও কোথায় দেখবেন — dashboard লিংকে 404 হলে কেউ tenantHref ছাড়া লিংক লিখেছে; subdomain-এ মার্কেটিং সাইট দেখালে নামটা সংরক্ষিত বা rootDomain মেলেনি; অচেনা subdomain-এ "not found" ইচ্ছাকৃত; নিজস্ব ডোমেইন verified হলেও অতিথি পৌঁছাতে না পারা এই পর্যায়ে স্বাভাবিক; ভুল ভাষা দেখালে NEXT_LOCALE cookie দেখুন।