@davaux/multisite

Run multiple sites from a single Davaux process. Each site gets its own hostname, routes, theme, and config. Sites share a common base routes directory and selectively override or extend it — the natural pattern for a CMS, SaaS platform, or any project where several sites share most of their code.

Installation

npm install @davaux/multisite

Concepts

Base routes (baseDir) — the shared route tree. Every site runs these by default.

Site routes (routesDir) — a per-site overlay. Routes here take priority over the base at the same URL pattern + type. Layouts, middlewares, and error pages at the same directory depth are replaced by the site version. Omit routesDir to run the base routes with only config changes.

Base islands (islandsDir on the config) — shared interactive components included in every site's client bundle.

Site islands (islandsDir on a site definition) — site-specific interactive components merged with the base islands into that site's client bundle.

Hostname matching — requests are dispatched by the Host header. Use '*' as a catch-all fallback for requests that don't match any registered hostname.

Site config — arbitrary data attached to each site definition and injected into every request via getSite<T>(ctx). Use it for database URLs, theme colours, feature flags — anything that varies per site.

Basic setup

1. Define sites

// multisite.config.ts
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineSites } from '@davaux/multisite'

const root = fileURLToPath(new URL('.', import.meta.url))

interface SiteConfig {
  name: string
  primaryColor: string
  dbUrl: string
}

export const sites = defineSites<SiteConfig>({
  baseDir: join(root, 'src/routes'),       // shared base routes
  islandsDir: join(root, 'src/islands'),   // shared islands (included in every site's bundle)
  sites: [
    {
      name: 'main',
      hostname: 'example.com',
      routesDir: join(root, 'sites/main/routes'),    // overrides + additions
      islandsDir: join(root, 'sites/main/islands'),  // main-only islands
      config: { name: 'Main Site', primaryColor: '#1d4ed8', dbUrl: process.env.MAIN_DB! },
    },
    {
      name: 'shop',
      hostname: ['shop.example.com', 'shop.example.com:3000'],
      routesDir: join(root, 'sites/shop/routes'),
      config: { name: 'Shop', primaryColor: '#7c3aed', dbUrl: process.env.SHOP_DB! },
    },
    {
      name: 'fallback',
      hostname: '*',            // catch-all — no routesDir, pure config override
      config: { name: 'Dev', primaryColor: '#374151', dbUrl: process.env.DEV_DB! },
    },
  ],
})

Use fileURLToPath(new URL('.', import.meta.url)) to compute absolute paths that work correctly in both development (TypeScript source) and production (compiled dist/).

2. Start the server

// server.ts
import { startMultisite } from '@davaux/multisite'
import { sites } from './multisite.config.js'

startMultisite(sites, { port: 3000, hostname: 'localhost' })

startMultisite combines build and serve into a single call. It automatically sets isDev from NODE_ENVtrue unless NODE_ENV=production. In dev mode it calls startMultisiteDev, which provides file watching, live reload, and TypeScript type checking identical to a regular davaux dev session.

3. Access site config in routes

// src/routes/_layout.tsx  (or any route, middleware, or layout)
import { defineLayout } from 'davaux'
import { getSite } from '@davaux/multisite'
import type { SiteConfig } from '../types.js'

export default defineLayout(({ children, ctx }) => {
  const site = getSite<SiteConfig>(ctx)

  return (
    <html lang="en">
      <head>
        <style>{`:root { --primary: ${site?.primaryColor ?? '#000'} }`}</style>
      </head>
      <body>{children as unknown as string}</body>
    </html>
  )
})

getSite<T>(ctx) returns undefined when called outside a multisite server. Guard with ?? or use optional chaining.

Route merging

When a site has a routesDir, its routes are overlaid on top of the base:

ConflictWinner
Same URL pattern + HTTP method in base and siteSite route wins
_layout.tsx at the same directory depthSite layout wins
_middleware.ts at the same directory depthSite middleware wins
_error.tsx at the same directory depthSite error page wins; falls back to base if absent
Route in base onlyBase route runs
Route in site onlySite route runs alongside base routes

Layout proximity

Layouts are matched by directory proximity — a layout in src/routes/ applies to routes in that directory tree. If your site has routes in sites/shop/routes/, those routes won't automatically inherit the base layout at src/routes/_layout.tsx.

The fix is a one-line re-export in the site's routes directory:

// sites/shop/routes/_layout.tsx
export { default } from '../../../src/routes/_layout.js'

This is intentional — it gives each site explicit control over its own layout without hidden inheritance.

Project structure

my-project/
├── multisite.config.ts         ← site definitions
├── server.ts                   ← startup (dev + prod)
├── build.ts                    ← production build script
├── src/
│   ├── islands/                ← shared islands (all sites)
│   │   └── ThemeToggle.tsx
│   └── routes/                 ← shared base routes
│       ├── _layout.tsx
│       ├── _error.tsx
│       ├── index.page.tsx
│       └── about.page.tsx
└── sites/
    ├── main/
    │   ├── islands/            ← main-only islands
    │   │   └── AdminPanel.tsx
    │   └── routes/             ← main site overrides
    │       └── _layout.tsx
    └── shop/
        └── routes/             ← shop-specific routes + overrides
            ├── _layout.tsx
            └── products.page.tsx

Production build

Create a build.ts at the project root:

// build.ts
import { buildMultisite } from '@davaux/multisite/build'
import { sites } from './multisite.config.js'

await buildMultisite(sites, { cwd: import.meta.dirname })

Add the build script to package.json:

{
  "scripts": {
    "dev": "node --watch --import tsx/esm server.ts",
    "build": "tsx build.ts",
    "start": "NODE_ENV=production node dist/server.js"
  }
}

buildMultisite reads baseDir and each site's routesDir directly from your config — no separate directory discovery needed. It compiles routes, layouts, middlewares, and server entry files to dist/ with esbuild, preserving the project structure so the compiled dist/multisite.config.js resolves paths correctly.

Three operating modes

1. Shared base + per-site overlay (layered CMS)

The most common pattern. All sites share a base route tree; individual sites override or extend specific routes.

sites: [
  { name: 'blog', hostname: 'blog.example.com', routesDir: './sites/blog/routes', config: {...} },
  { name: 'docs', hostname: 'docs.example.com', routesDir: './sites/docs/routes', config: {...} },
]

2. Fully independent co-located sites

No shared baseDir — each site is completely standalone but co-located in one process.

{
  sites: [
    { name: 'marketing', hostname: 'example.com', routesDir: './sites/marketing/routes', config: {...} },
    { name: 'app',       hostname: 'app.example.com', routesDir: './sites/app/routes', config: {...} },
  ]
  // no baseDir
}

3. Same routes, different config (SaaS tenancy)

All tenants share the same routes; getSite(ctx) provides the tenant-specific data.

{
  baseDir: './src/routes',
  sites: [
    { name: 'acme',  hostname: 'acme.example.com',  config: { dbUrl: '...', theme: 'blue' } },
    { name: 'globex', hostname: 'globex.example.com', config: { dbUrl: '...', theme: 'green' } },
    // no routesDir on any site
  ]
}

Using plugins

Davaux plugins (like @davaux/mdx and @davaux/markdown) contribute two things: scanner suffix extensions (so .page.mdx files are discovered) and esbuild transforms (so those files compile correctly). Pass the same plugin list to both startMultisite and buildMultisite — they work the same way a regular davaux dev / davaux build cycle does.

// server.ts
import { startMultisite } from '@davaux/multisite'
import { mdx } from '@davaux/mdx'
import { sites } from './multisite.config.js'

startMultisite(sites, {
  port: 3000,
  cwd: import.meta.dirname,  // needed so .davaux-multisite/ lands in the right place
  plugins: [mdx()],
})
// build.ts
import { buildMultisite } from '@davaux/multisite/build'
import { mdx } from '@davaux/mdx'
import { sites } from './multisite.config.js'

await buildMultisite(sites, {
  cwd: import.meta.dirname,
  plugins: [mdx()],
})

In dev mode, startMultisite compiles all route files with esbuild (applying the plugin transforms) to a .davaux-multisite/ temp directory before starting the server — exactly how davaux dev handles it. Non-TypeScript route types like .page.mdx are fully supported.

You do not need to set extraSuffixes in your MultisiteConfig when using plugins — the plugins array on startMultisite / buildMultisite is the single source of truth.

API reference

defineSites<T>(config)

Identity function — returns config unchanged. Provides TypeScript inference for the site config type T.

The config object accepts:

FieldTypeDescription
baseDirstringAbsolute path to the shared base routes directory
islandsDirstringAbsolute path to shared islands — included in every site's client bundle
publicDirstringAbsolute path to a shared public directory — static files served before route dispatch
clientEntrystringAbsolute path to a shared client-side entry file — compiled to /_davaux/client.js and injected on every page. Per-site clientEntry overrides this.
sitesSiteDefinition<T>[]Per-site definitions (see below)
extraSuffixes[string, RouteType][]Low-level extra route suffixes. Prefer passing plugins to startMultisite / buildMultisite instead — they set this automatically.

Each SiteDefinition accepts:

FieldTypeDescription
namestringUnique site name (used in logging and island bundle paths)
hostnamestring | string[]Hostname(s) that route to this site. Use '*' as a catch-all.
routesDirstringPer-site routes overlay — wins over baseDir at matching patterns
islandsDirstringPer-site islands — merged with config.islandsDir into this site's client bundle
publicDirstringPer-site public directory — takes priority over config.publicDir when both contain the same path
clientEntrystringPer-site client-side entry file — overrides config.clientEntry for this site
configTArbitrary per-site data — accessible via getSite<T>(ctx)

buildMultisiteApps<T>(config, options?)

Scan all route directories, merge each with the shared base, and return a Map<hostname, SiteEntry> of compiled apps ready for dispatch.

const apps = await buildMultisiteApps(sites, {
  isDev: true,              // enables dev compilation + cache-busting (default: false)
  clientScripts: [],        // additional <script> tags injected into every page
  clientStylesheets: [],    // additional <link> tags injected into every page
  basePath: '',             // URL prefix for all routes (e.g. '/app')
  appMiddlewarePath: '...', // absolute path to an app-level middleware file
  cwd: import.meta.dirname, // project root for .davaux-multisite/ temp dir (dev only)
  plugins: [mdx()],         // Davaux plugins — same list as buildMultisite
  paths: { '~/*': ['./src/*'] }, // tsconfig-style path aliases forwarded to esbuild
  external: [],             // extra packages to externalize beyond node:*, davaux, @davaux/multisite
})

When isDev: true, all route files are compiled via esbuild into .davaux-multisite/ before the server starts, applying any plugin transforms. In production (isDev: false) the files are assumed to already be compiled .js modules.

startMultisiteServer<T>(apps, options?)

Start an HTTP server that dispatches each request to the matching site by hostname.

const server = startMultisiteServer(apps, {
  port: 3000,
  hostname: 'localhost',
})

Returns the http.Server instance so you can attach signal handlers.

startMultisite<T>(config, options?)

Convenience wrapper — the recommended entry point for both dev and production. Sets isDev automatically from NODE_ENV (true unless NODE_ENV=production).

  • Dev mode (isDev: true): delegates to startMultisiteDev — esbuild file watching, live reload, and TypeScript type checking.
  • Production mode: calls buildMultisiteApps + startMultisiteServer — a one-shot compile from pre-built dist/ files.
startMultisite(sites, {
  port: 3000,
  hostname: 'localhost',
  cwd: import.meta.dirname,  // required for file watching and plugin resolution
  plugins: [mdx()],
})

When cwd is set and no middleware path is provided, src/middleware.ts (dev) or src/middleware.js (production) is auto-detected and applied as app-level middleware.

startMultisiteDev<T>(config, options?)

Full dev server with incremental rebuilds, live reload, and TypeScript type checking. Called automatically by startMultisite in dev mode — call it directly only if you need to bypass the startMultisite convenience layer.

  • esbuild context() + watch() for all route files (content changes rebuild incrementally without restarting)
  • fsWatch (recursive, rename events) on every route and island directory — adding or removing a file triggers a structural rebuild without restarting the server
  • SSE live reload: /_davaux/livereload stream, /_davaux/livereload.js client script, SharedWorker fan-out
  • tsc --noEmit --watch spawned alongside the server if tsconfig.json is present at cwd

getSite<T>(ctx)

Retrieve the current site's config from a request context. Returns undefined when called outside a multisite server.

import { getSite } from '@davaux/multisite'

const site = getSite<SiteConfig>(ctx)

mergeScanResults(base, overlay)

Merge two ScanResult objects — overlay wins at matching URL pattern + type (routes) or dirPath (layouts, middlewares). Exported for testing and advanced use cases; called internally by buildMultisiteApps.

dispatchToSite<T>(apps, req, res)

Look up the correct site by Host header, inject site config, and dispatch. Returns false if no site matches. Useful for embedding multisite dispatch in a custom HTTP server or for testing without a live server.

const server = createServer(async (req, res) => {
  const handled = await dispatchToSite(apps, req, res)
  if (!handled) { res.writeHead(404); res.end() }
})

buildMultisite<T>(config, options?)@davaux/multisite/build

Compile a multisite project to JavaScript using esbuild. Requires esbuild to be installed (present in any project that depends on davaux).

import { buildMultisite } from '@davaux/multisite/build'

await buildMultisite(sites, {
  cwd: import.meta.dirname,  // project root (default: process.cwd())
  outDir: './dist',          // output directory (default: {cwd}/dist)
  external: [],              // extra packages to externalize beyond node:*, davaux, @davaux/multisite
  plugins: [],               // Davaux plugins (same list as davaux.config.ts)
  paths: { '~/*': ['./src/*'] }, // tsconfig-style path aliases forwarded to esbuild
})