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:

  1. 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" at lib/site/theme/registry.ts:12), plus lasheyeisland, demostudio, noir, blush, and freshfade. A newly-provisioned tenant renders neutral until 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) and demostudio (an internal test theme) are deliberately excluded from this base list and from signup. Issue #12: the lasheyeisland tenant's own admin Settings switcher is the ONE exception — getOfferableThemesForTenant(tenantSlug) appends lasheyeisland back in only when the acting tenant's slug is literally "lasheyeisland", and updateThemeSvc (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.

  2. Override mode (the Studio editor). Once an owner edits their site in the Studio (the admin /admin/editor surface), the theme is materialized into an editable, self-contained site_config and the site renders through OverridePageRenderer instead of the theme's default layout. A tenant that has never used the Studio has no site_config and 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 bookings module 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 Postgres FOR UPDATE row 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 request Host (gated through isPlatformHost so an attacker-supplied host can't leak) and feeds metadataBase (app/(site)/[locale]/layout.tsx:62).
  • Per-page canonical + hreflang alternates come from localizedAlternates(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), and faqPageSchema (:80, on the home page, gated on the website module).

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.