nuxt-surface
| Install | |
|---|---|
npm install nuxt-surface |
|
| Latest Version: | 0.5.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 13, 2026 |
| Links: | GitHub · npm |
nuxt-surface
Serve several sites from one Nuxt app, each with its own pages at the same URLs:
example.com/→ your landing page.docs.example.com/→ documentation.acme.example.com/→ Acme's workspace.ideas.acme.org/→ the same workspace on its custom domain.unknown.example.com/→ 404.
Nuxt's file router has one pages/index.vue, so / can only be one page.
With nuxt-surface, you put each site's pages in its own folder, and every page load picks one of these page sets, called surfaces, usually by hostname.
Components, layouts, plugins, server routes, and the deployment stay shared.
All customer workspaces share one surface; which customer it is stays in your own request data.
- Server rendering and hydration always agree on the surface, and concurrent requests don't affect each other's choice.
- Pages and components read the current surface with a typed
useSurface(). - The build checks that every page belongs to exactly one surface.
nuxt-surface/hostsmaps surfaces to subdomains and builds links between them.
Need separate modules, builds, or runtimes per site?
Use separate apps, for example with nuxt-multi-app.
The Nuxt module requires Nuxt 4 (4.0.1 or newer); nuxt-surface/hosts works without Nuxt.
Setup
npm install nuxt-surface
Put each site's pages under its own prefix and declare the prefixes as surfaces:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["nuxt-surface"],
surface: {
surfaces: {
site: "/surfaces/site",
docs: "/surfaces/docs",
tenant: "/",
},
},
})
app/pages/
├── index.vue # tenant: /
├── portal.vue # tenant: /portal
└── surfaces/
├── site/index.vue # site: /
├── docs/index.vue # docs: /
└── docs/guide/[id].vue # docs: /guide/:id
Pages outside the other prefixes belong to the surface declared as /.
Omit it if every page lives under a prefix.
Any static path without a trailing slash works as a prefix; /surfaces is just an example.
Pick the surface in app/router.options.ts.
A fixed ID is enough to try it:
// app/router.options.ts
import type { RouterConfig } from "nuxt/schema"
import { selectSurfaceRoutes } from "#nuxt-surface"
export default {
routes: routes => selectSurfaceRoutes(routes, () => "site"),
} satisfies RouterConfig
selectSurfaceRoutes() serves the selected surface's pages without its prefix, so surfaces/docs/guide/[id].vue answers at /guide/42.
Choosing by hostname
The selector runs once per page load, while Nuxt creates the router, so it can't use the route, the router, or useSurface().
It must synchronously return a surface ID, or null to serve no pages, for example on an unknown customer host.
The data it needs has to be ready by then on both the server and the browser.
nuxt-request-context prepares such data per request, both with SSR and with ssr: false:
npm install nuxt-request-context
// nuxt.config.ts, alongside the surface configuration above
modules: [
["nuxt-request-context", { provider: "./server/request-context.ts" }],
"nuxt-surface",
],
runtimeConfig: {
public: {
baseUrl: "https://example.com",
},
},
baseUrl points to your main domain, also for requests to a customer's own domain.
Surfaces on subdomains need a domain name there, such as example.localhost in development rather than 127.0.0.1.
nuxt-surface/hosts maps surfaces to subdomains of a base hostname.
It doesn't depend on Nuxt, so your app, Nitro, and server code outside Nuxt can share one map:
// shared/product-surfaces.ts
import { defineSurfaceHosts } from "nuxt-surface/hosts"
export const productSurfaces = defineSurfaceHosts({
site: null, // example.com
docs: "docs", // docs.example.com
})
// server/request-context.ts
import { getRequestURL } from "h3"
import { defineRequestContextProvider } from "nuxt-request-context/provider"
import { productSurfaces } from "#shared/product-surfaces"
export default defineRequestContextProvider(async event => {
const baseHostname = new URL(useRuntimeConfig(event).public.baseUrl).hostname
const host = productSurfaces.resolve(getRequestURL(event).hostname, baseHostname)
if (host.surface !== undefined) return { surface: host.surface }
// Other hosts are customer workspaces; findWorkspace() is your lookup by host.subdomain or host.domain.
const workspace = await findWorkspace(host)
return { surface: workspace ? ("tenant" as const) : null, workspace }
})
// app/router.options.ts
import type { RouterConfig } from "nuxt/schema"
import { useRequestContext } from "#nuxt-request-context/client"
import { selectSurfaceRoutes } from "#nuxt-surface"
export default {
routes: routes => selectSurfaceRoutes(routes, () => useRequestContext().surface),
} satisfies RouterConfig
Any other way to provide the data works too, as long as it is ready before Nuxt's router plugin on both the server and the browser.
A regular app plugin runs too late, and with ssr: false the data must arrive in the HTML.
resolve() returns { surface } for a mapped host.
Other hosts are up to your app: a subdomain of the base hostname returns { subdomain }, and any other hostname returns { domain }.
The result type is ResolvedHost<Id>, and unmapped hosts are UnmappedHost.
Hostnames are case-insensitive ASCII, as new URL(url).hostname returns them.
Write internationalized surface subdomains in Punycode, and store customer subdomains and domains in ASCII for lookups.
When the selector returns null, the router has no routes and Nuxt renders its 404 page.
With SSR the response status is 404; with ssr: false the HTML comes with status 200 and the browser shows the 404 page.
Links to another surface are full page loads.
getUrl() builds them on the surface's host, keeping the protocol and port of the base URL:
const guideUrl = productSurfaces.getUrl(useRuntimeConfig().public.baseUrl, "docs", "/guide/42")
path resolves against the base URL and must stay on its origin.
For asset URLs, which app.cdnURL can make absolute, use new URL(asset, productSurfaces.getUrl(baseUrl, "site")).href instead.
Links to customer workspaces are up to your app.
getHostname(baseHostname, surface) returns just the hostname of a surface.
subdomains maps each surface to its lowercased subdomain, or null for the base hostname, for example to keep customers from registering a surface's subdomain.
Reading the surface
const surface = useSurface()
useSurface() is auto-imported and also available from #nuxt-surface.
It returns one of the declared IDs, typed as SurfaceId after nuxt prepare, or null when the selector returned null.
Plugins, global middleware, and error.vue still run without a surface, so handle null there.
The surface stays the same for the whole page session, including client-side navigation and page HMR.
To switch surfaces, load a new page with a regular link, <NuxtLink external>, or location.assign().
Pages
Only the top-level routes of the selected surface change: they lose the surface prefix.
Child routes, parent pages, layouts, middleware, page metadata, and route names stay as they are.
Route names keep the prefix, so named navigation uses surfaces-docs-guide-id.
- A parent page can't span surfaces.
Give each surface its own parent page, such as
surfaces/docs.vue, instead of one wrapper around all of them. - Nested absolute paths, aliases, and redirects are used as written, so write them as public paths, such as
/guide/42. - If a page computes its
definePageMeta()path, the build can't check it, and the page goes to the surface whose prefix matches its runtime path. Withexperimental.scanPageMetadisabled, this applies to everydefinePageMeta()path.
The build fails when a page belongs to no surface or a surface has no pages.
Redirects from routeRules work on every surface and don't count as its pages.
To change routes further, transform the selected array. Route records are shared between server requests, so create new records instead of mutating them:
routes: (routes) => selectSurfaceRoutes(routes, () => useRequestContext().surface)
.map((route) => ({ ...route, meta: { ...route.meta, appFlag: true } })),
Testing
When tests share one Nuxt app, its router keeps the surface selected at startup.
replaceSurface() switches the running app to another surface, so a test can render that surface's pages and links:
import { replaceSurface } from "#nuxt-surface/testing"
const restoreSurface = await replaceSurface("docs")
// mount components, navigate, assert
await restoreSurface()
It reruns routes() from app/router.options.ts without calling the selector, then installs the returned routes.
useSurface() returns the new ID right away.
null selects no surface.
- Routes added with
router.addRoute()are dropped. - The current route is not matched again; navigate when the test needs a page of the new surface.
- Restore after mounted components are unmounted and before any test cleanup that navigates the router to a baseline route, which must exist on the original surface.
With nuxt-vitest-shared-app, register the restore as a per-test cleanup, which runs at that point:
onNuxtTestCleanup(await replaceSurface("docs"))
Limits
- Choosing a surface is not access control: the code of every surface ships in the same build.
- Nuxt features that read pages at build time, such as typed pages, inline route rules, and prerender route discovery, see the prefixed paths.
- Route rules don't know about surfaces, and a prerendered page keeps the surface its selector picked at build time.
- If you cache HTML or payloads, include the hostname, or whatever else picks the surface, in the cache key. Otherwise one site's pages are served on another.
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.5.0 | ^3.0 | MIT | |
| 0.4.1 | ^3.0 | MIT | |
| 0.4.0 | ^3.0 | MIT | |
| 0.3.0 | ^3.0 | MIT | |
| 0.2.0 | ^3.0 | MIT | |
| 0.1.0 | ^3.0 | MIT |