Multiple sites in one app
Use multi-site mode when one deployment serves genuinely separate web experiences: a marketing site, an application dashboard, and tenant-owned domains, for example. It gives each experience its own document root and route table while ordinary src/app API routes remain host-agnostic.
Folder layout
Section titled “Folder layout”Declare sites explicitly. Each site lives in src/web/$sites/<name>/, where the folder name is the site key; $sites and the site folder never appear in URLs. The folder holds that site’s root.tsx, 404/error pages, and routes; its root owns the site’s document.
src/web/ $sites/ landing/root.tsx landing/index.page.tsx platform/root.tsx platform/index.page.tsx tenant/root.tsx tenant/listings/[id].page.tsx tenantAdmin/root.tsx tenantAdmin/index.page.tsx components/ # shared components, never pagesFor the Real-Estate app, landing serves estates.app, platform serves the dashboard, tenant serves public agency sites, and tenantAdmin is the dashboard under /admin on each tenant domain.
Configure fixed and tenant sites
Section titled “Configure fixed and tenant sites”hosts contains exact, lowercase hostnames without a port. List www explicitly if it is an alias. A dynamic: true site is claimed by the one application-level resolver instead; a site cannot use both forms.
import type { WebConfigurations } from "@warlock.js/web";import { Tenant } from "app/tenants/tenant.model";
const web: WebConfigurations = { sites: { landing: { hosts: ["estates.app", "www.estates.app"], localeRouting: { strategy: "prefix-except-default" }, }, platform: { hosts: ["app.estates.app"] }, tenant: { dynamic: true, localeRouting: {} }, tenantAdmin: { dynamic: true, basePath: "/admin" }, }, resolveHost: async ({ host, request }) => { const domain = await Tenant.domains.findOne({ host }); if (!domain) return null;
const tenant = domain.tenant; const site = domain.kind === "dashboard" ? "tenantAdmin" : "tenant";
return { site, key: String(tenant.id), indexable: tenant.isPublic, shared: { tenant: tenant.publicSummary(), theme: tenant.theme }, }; }, resolveCache: { ttl: 60 }, unknownHost: "not-found", tlsAsk: "/.well-known/warlock/domain",};
export default web;Upgrade: move each src/web/(group) site folder to src/web/$sites/<key> and delete pages from web.sites.
resolveHost({ host, request }) returns { site, key, indexable?, shared? } or null. host is normalized (lowercase, without port or a trailing dot), and request is available when a lookup needs request data. The Real-Estate lookup owns Tenant.domains[{ host, kind }]; it returns only a declared dynamic: true site. null produces a controlled 404; a thrown lookup error remains a 500.
resolveCache: { ttl } caches the resolver result by host for ttl seconds. It is off by default: a resolver that varies by a cookie or header must not reuse one visitor’s result for every visitor on that host. Enable it only when the answer depends on the host alone.
basePath puts an entire site below a path prefix on its hosts. In the example, the tenant-admin site is selected for /admin and descendants. unknownHost defaults to a plain 404; set it to a configured site key to use that site as a fallback.
localeRouting on a site replaces the global web.localeRouting for that site. Omit it to inherit the global setting; use {} to disable locale routing for that site. The global locale codes and default locale still come from app.localeCodes and app.localeCode.
Local development
Section titled “Local development”Browsers resolve *.localhost to loopback, so point local configuration at the same shape as production, including the development port:
estates.localhost:2030app.localhost:2030acme.localhost:2030These hosts belong in application environment/configuration. Web does not add a separate hosts-file or environment-variable convention.
Use the selected site on the server
Section titled “Use the selected site on the server”In multi-site mode loaders and actions receive ctx.site (PageSite). It contains the configured site key, normalized host, selected basePath, and tenantKey for a resolver-selected site.
import type { PageActionContext, PageLoaderContext } from "@warlock.js/web";
export async function loader({ site }: PageLoaderContext<undefined, undefined>) { return { tenantId: site?.tenantKey, host: site?.host };}
export async function action({ site, response }: PageActionContext<undefined>) { if (!site?.tenantKey) return response.notFound(); return { savedFor: site.tenantKey };}The resolver’s shared object merges into the normal shared payload before page middleware runs. That is the hand-off for per-tenant themes: the tenant root reads shared.theme rather than deriving a theme again in the browser. See the multi-theme guide.
Routes and links
Section titled “Routes and links”The same page path may exist in different sites. Generated route names always get the site-key prefix, whatever route form produced them: landing.pricing and tenant.listings.show. Explicit route names remain global across the app.
href() returns a relative path for the current site and an absolute URL for another site. A dynamic target needs its runtime hostname as $host; $host is used only for the origin, not the route path. siteUrl() returns the current site’s origin plus its basePath.
import { href, siteUrl } from "@warlock.js/web";
const currentSite = siteUrl();const platformHome = href("platform.home");const tenantListing = href("tenant.listings.show", { $host: "acme.estates.app", id: "42" });<Link> and navigateTo() take the same route names. A link to another site loads that site’s page in full rather than as a same-site client transition:
import { Link } from "@warlock.js/web";
<Link to="platform.home">Open the platform</Link>;src/config/web.ts can import your resolver from app code (app/tenants/...); warlock build resolves those path aliases when it reads web.sites.
Search and TLS endpoints
Section titled “Search and TLS endpoints”With the normal sitemap and robots features enabled, /sitemap.xml and /robots.txt are selected per site. A site’s sitemap contains only that site’s pages and uses that request’s site origin and basePath. A resolver-selected dynamic site is omitted from indexing unless its resolver result has indexable: true: its sitemap is a 404 and its robots response disallows all crawling otherwise.
Set web.tlsAsk to expose a Caddy on-demand TLS ask endpoint. It requires web.sites; Warlock allows a domain only when it is a configured fixed host or resolveHost claims it.
{ on_demand_tls { ask http://127.0.0.1:<port>/.well-known/warlock/domain }}Configuration and boot errors
Section titled “Configuration and boot errors”Multi-site boot checks fail early and name the offending configuration or file. Fix these rather than relying on selection order:
- a top-level
src/web/root.tsxexists in multi-site mode; - a
*.page.tsxsits outside every configured site folder; - a
$sites/<name>folder has no matchingweb.sites.<name>entry; - a
web.sites.<name>entry has no$sites/<name>folder; - a configured site folder has no
root.tsx; - two fixed sites use the same host and
basePath; - a page route shadows an
src/approute for the same method; - a dynamic site has no
web.resolveHost, or a resolver exists without a dynamic site; unknownHostnames a site key that is not configured.
An exact listed host always wins over the resolver, even if the resolver could claim it. This keeps the platform dashboard from becoming a tenant site.
Server page-cache entries are partitioned by the selected site and resolver tenant key, so identical paths from different sites or tenants cannot share an entry.
What stays out
Section titled “What stays out”Multi-site mode does not select tenants from a path or session, share one root between sites, or prefetch cross-site <Link> navigations. It also does not restrict src/app APIs to a site: APIs stay host-agnostic and application middleware can restrict them when needed.