Guide: the public tenant site
This guide explains the public tenant site — the marketing-and-booking website your
visitors see at https://<slug>.wahylabs.com. It is written for two readers: a tenant owner
who wants to understand what their site does and how to shape it, and an engineer who needs
the routes, components, and services behind it. Technical claims cite file:line or a route
path so they can be checked against the code.
For how tenants and subdomains are resolved (the mechanism that turns
lasheyeisland.wahylabs.com into "tenant lasheyeisland"), see tenancy. For
managing content (Gallery, FAQs, Journal, Settings, Bookings) the owner uses the admin CMS
at /admin — that surface has its own guide; this document is about the public site only.
What the public site is
Every tenant gets its own public website, served on its own subdomain. Lasheyeisland's site
lives at lasheyeisland.wahylabs.com; another tenant's site lives at <their-slug>.wahylabs.com.
The same code renders every tenant's site — what differs per tenant is the theme (colors,
fonts, layout, copy), the content (services, gallery photos, FAQs, journal posts), and the
settings (business name, WhatsApp number, studio address).
The site is locale-prefixed: every public URL carries a language segment, /en (English)
or /sw (Swahili) — for example https://<slug>.wahylabs.com/en. This is defined in
i18n/routing.ts:4 (locales: ["en", "sw"], localePrefix: "always"). See
Languages (i18n) below.
The public site is deliberately rendered per-request (export const dynamic = "force-dynamic",
app/(site)/[locale]/layout.tsx:34). It is never statically cached by path, because two tenants
share a URL path and must never share each other's host-derived SEO metadata. See
SEO below.
What renders on it
All public routes live under app/(site)/[locale]/. Only the home page is always present;
the About, Contact, and custom pages exist only if the tenant built them in the Studio editor
(see How the site is built).
Home — /{locale}
The home page (app/(site)/[locale]/page.tsx) is composed of blocks stacked top to bottom.
The default set of blocks and their order come from the tenant's theme; a block is a self-contained
section such as a hero banner, a services list, a photo gallery, a price list, an FAQ accordion,
or a booking call-to-action.
There is no standalone /gallery or /booking route — the gallery and the booking
call-to-action are blocks on the home page, not separate pages. Deep-linking to the home page
looks like https://<slug>.wahylabs.com/en.
The available block types are registered in lib/site/blocks/registry.tsx (BLOCK_REGISTRY,
lib/site/blocks/registry.tsx:115): hero, services, howItWorks, gallery, about,
testimonials, bookingCTA, faq, priceList, and contact. See
How the site is built.
Two blocks are gated by the tenant's website module entitlement: the gallery and FAQ blocks
(and the FAQ structured data) stop rendering if a tenant is downgraded off website
(filterBlocksByWebsiteEntitlement, called in app/(site)/[locale]/page.tsx:128). Module
entitlements are covered in tenancy.
About / Contact / custom pages — /{locale}/about, /{locale}/contact, /{locale}/{slug}
These are optional, tenant-built pages. app/(site)/[locale]/about/page.tsx and
app/(site)/[locale]/contact/page.tsx render an About/Contact page only if the tenant has
created one in the Studio editor; a tenant with no such page returns a 404 (notFound() when
site_config.pages.about / site_config.pages.contact is absent — see the file headers). They
are not always-on standard pages.
The catch-all app/(site)/[locale]/[...slug]/page.tsx renders a custom page the tenant
defined in the Studio (site_config.pages[slug]), one segment only. Any slug with no matching
page config returns a 404. Next.js resolves the more specific routes (services/[slug],
journal/*, about, contact, the locale root) before this catch-all, so those never collide.
Journal — /{locale}/journal and /{locale}/journal/{slug}
app/(site)/[locale]/journal/page.tsx renders the blog/journal listing, and
app/(site)/[locale]/journal/[slug]/page.tsx renders a single post. Content comes from the
tenant's published posts (getPublishedPosts, lib/data.ts:244). The Journal is the
opt-in journal module; the owner writes posts in the admin CMS. Deep-link:
https://<slug>.wahylabs.com/en/journal.
Service detail — /{locale}/services/{slug}
app/(site)/[locale]/services/[slug]/page.tsx renders a single service's detail page
(getServiceBySlug, lib/data.ts:223, wrapping getServiceBySlugSvc at
lib/services/services.ts:84), with its own serviceSchema structured data
(lib/jsonld.ts:43) and a "Book" button. The services themselves are managed by the owner in the
admin CMS and also feed the home-page services and price-list blocks.
How the site is built
The public site renders in one of two modes, both driven by the same block registry:
-
Theme mode (the default). The tenant's chosen theme supplies the block layout, the design tokens (colors/fonts), and the copy. Themes are code, registered in
lib/site/theme/registry.ts(THEMES,lib/site/theme/registry.ts:13). Six ship today:neutral(the brand-neutral default,DEFAULT_THEME_ID = "neutral"atlib/site/theme/registry.ts:12), pluslasheyeisland,demostudio,noir,blush, andfreshfade. A newly-provisioned tenant rendersneutraluntil its owner picks a theme.Of those, four are offerable — the ones a tenant may actually choose at signup or from the admin theme switcher:
neutral,noir,blush,freshfade(OFFERABLE_THEME_IDS,lib/site/theme/offerable.ts:26).lasheyeisland(tenant #1's hand-built brand) anddemostudio(an internal test theme) are deliberately excluded from this base list and from signup. Issue #12: thelasheyeislandtenant's own admin Settings switcher is the ONE exception —getOfferableThemesForTenant(tenantSlug)appendslasheyeislandback in only when the acting tenant's slug is literally"lasheyeisland", andupdateThemeSvc(lib/services/settings.ts) enforces the identical restriction server-side, so the picker can never be the only thing standing between another tenant and that theme id. -
Override mode (the Studio editor). Once an owner edits their site in the Studio (the admin
/admin/editorsurface), the theme is materialized into an editable, self-containedsite_configand the site renders throughOverridePageRendererinstead of the theme's default layout. A tenant that has never used the Studio has nosite_configand renders byte-for-byte the same as theme mode (app/(site)/[locale]/page.tsx:92-125). The editor lets an owner reorder blocks, edit copy, swap hero/about images, and add About/Contact/custom pages.
For an owner, the mental model is simple: pick a theme, then optionally open the Studio to
rearrange sections, change wording, and add pages. For a deeper engineering view of tokens,
the 8+3 token model, and the materialize-then-override design, see the theming notes in the
project CLAUDE.md and the block/theme code (lib/site/blocks/registry.tsx,
lib/site/theme/registry.ts).
The booking flow
A visitor can book in two ways, both surfaced by the hero and bookingCTA blocks.
1. In-app booking modal. Clicking a "Book" button opens BookingModal
(components/site/booking/BookingModal.tsx), a three-step flow: choose a location
(studio vs. house-call, when the tenant offers both) → pick a date and an open time slot →
enter contact details and confirm. On submit it calls the bookSlot server action
(lib/actions/availability.ts:133), which:
- rate-limits by IP and validates the details;
- confirms the tenant's
bookingsmodule is enabled — a disabled tenant returns a clean"Bookings are not available."instead of erroring (lib/actions/availability.ts:156); - refuses new bookings if the tenant is read-only, e.g. a lapsed trial /
past_due(lib/actions/availability.ts:164) — again a clean message, never a 500; - claims the slot atomically via
bookSlotSvc(lib/services/availability.ts:331), which uses a PostgresFOR UPDATErow lock so exactly one visitor wins a contested slot; the loser sees "That time was just taken".
2. WhatsApp deep-link. When the tenant has a WhatsApp number set
(settings.whatsappNumber), the hero and booking-CTA blocks and a floating button render a
WhatsApp link pre-filled with a booking message (lib/site/blocks/registry.tsx:119-145,
:264-283; components/site/WhatsAppButton.tsx wired in app/(site)/[locale]/layout.tsx:148).
A tenant with no WhatsApp number simply doesn't show these WhatsApp CTAs.
The visitor's experience: they never see the tenant's private owner contacts — the public site
is handed only a public-safe projection of settings (toPublicSettingsDTO, applied in
app/(site)/[locale]/layout.tsx:95), so the owner's notification email/WhatsApp never reach the
browser.
Languages (i18n)
The site is bilingual: English (/en) and Swahili (/sw), with the locale as the first path
segment on every public URL (i18n/routing.ts, localePrefix: "always"). Rendering uses
next-intl; all display copy comes from the theme's per-locale message catalog (read with
t.raw(key) throughout the block registry, e.g. lib/site/blocks/registry.tsx:119).
How a visitor switches: the locale lives in the URL, so navigating to the same page under the
other prefix (/sw/... vs /en/...) switches language. A visitor who lands without a locale
prefix is redirected to the tenant's default locale by the proxy (see the locale-routing note in
CLAUDE.md and tenancy); the tenant's default locale is a per-tenant setting.
Today sw is a scaffold copy of en copy, kept complete so no message key is ever missing.
SEO
Each tenant's site emits its own canonical URL, Open Graph tags, and JSON-LD structured data, all derived from the request host so tenant A never advertises tenant B's URLs:
- Absolute origins come from
getRequestOrigin()(lib/site.ts), which reads the requestHost(gated throughisPlatformHostso an attacker-supplied host can't leak) and feedsmetadataBase(app/(site)/[locale]/layout.tsx:62). - Per-page canonical +
hreflangalternates come fromlocalizedAlternates(path, locale)(lib/site.ts:201), used on the home, about, custom, journal, and service routes. - Structured data is built per-request in
lib/jsonld.ts:beautySalonSchema(lib/jsonld.ts:11, in the layout),serviceSchema(:43, on service pages),blogPostingSchema(:63, on journal posts), andfaqPageSchema(:80, on the home page, gated on thewebsitemodule).
Because these values are host-derived and a path is shared across tenants, the whole (site)
subtree (plus robots.txt / sitemap.xml) must stay dynamic — enforced by
export const dynamic = "force-dynamic" (app/(site)/[locale]/layout.tsx:34) and by the CI
guard scripts/verify-site-dynamic.mjs. Caching by path would serve one tenant's SEO metadata
to every tenant on that path.