Skip to content

Latest commit

 

History

History
390 lines (321 loc) · 17 KB

File metadata and controls

390 lines (321 loc) · 17 KB

Framework-Mode Integration (Nuxt 4)

This guide shows how to run the @modular-vue family inside a Nuxt 4 app with @modular-vue/nuxt. Nuxt owns the Vue app and the vue-router instance, so the integration is the router-owning path over @modular-vue/runtime (registry.resolve()): the runtime grafts every module's createRoutes() subtree onto Nuxt's router via router.addRoute() and installs the modular contexts (shared dependencies, navigation, slots, modules) on the Nuxt Vue app.

Read Getting started with Vue Router first for the library-agnostic tour of modules, the registry, zones via meta, and stores. This document focuses on the Nuxt integration seam. For the two resolve() / resolveManifest() modes in a plain vue-router SPA, see Shell Patterns for Vue Router.

Status: @modular-vue/nuxt is 0.1.0, experimental. The runtime installer is covered by the package test suite; the Nuxt module is a thin wrapper that injects a runtime plugin. Breaking changes between 0.x minors are possible.

Why Nuxt uses the router-owning path

In a plain vue-router SPA you choose between two modes (see the shell-patterns guide). Nuxt removes the choice: it creates the vue-router instance for you, so there is no createRouter({ routes }) call for the library to feed resolveManifest().routes into. Instead the runtime takes the router Nuxt already built and grafts module routes onto it at runtime — exactly what registry.resolve({ router }) does. @modular-vue/nuxt wires that from inside a Nuxt plugin.

Concern How Nuxt handles it
Vue app creation Nuxt owns it (nuxtApp.vueApp); the manifest is installed on it as a plugin.
Router creation Nuxt owns it (nuxtApp.$router); module routes are grafted via addRoute().
Provider stack Installed app-wide via nuxtApp.vueApp.use(manifest).
Per-request state Build the registry per request inside the plugin (see SSR).

Setup

Install

npm install @modular-vue/nuxt @modular-vue/runtime @modular-vue/core @modular-vue/vue

Define the registry as a factory

Export a factory that builds a fresh registry each time it's called. Under SSR a new Nuxt app is created per request, and registry.resolve() is single-use, so a per-request registry is required (a singleton throws on the second request).

// modular/registry.ts
import { createRegistry } from "@modular-vue/runtime";
import billingModule from "./modules/billing";
import type { AppDependencies, AppSlots } from "./types";
import { createAuthStore, httpClient } from "./services";

export default function buildRegistry() {
  const registry = createRegistry<AppDependencies, AppSlots>({
    stores: { auth: createAuthStore() },
    services: { httpClient },
    slots: { commands: [] },
  });
  registry.register(billingModule);
  return registry;
}

Modules are the same plain defineModule objects as everywhere else — nothing Nuxt-specific about them:

// modular/modules/billing.ts
import { defineModule } from "@modular-vue/core";
import type { RouteRecordRaw } from "vue-router";
import BillingPage from "./BillingPage.vue";

export default defineModule<AppDependencies, AppSlots>()({
  id: "billing",
  version: "1.0.0",
  createRoutes: (): RouteRecordRaw => ({
    path: "billing",
    component: BillingPage,
    meta: { pageTitle: "Billing" },
  }),
  navigation: [{ label: "Billing", to: "/billing", group: "finance" }],
  requires: ["auth"],
});

Wire it — two options

Option A: the Nuxt module (zero-config)

Add the module and point it at the registry factory:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@modular-vue/nuxt"],
  modularVue: {
    registry: "~/modular/registry",
    // Optional: graft module routes under a named shell route/page.
    parentRouteName: "app",
  },
});

The module injects a runtime plugin that calls installModularApp and exposes the resolved manifest as useNuxtApp().$modular. Only serializable options (registry, parentRouteName) can flow through nuxt.config.ts.

Option B: your own plugin (full control)

Write the plugin yourself when you need the non-serializable options — authGuard, providers, slotFilter, or onModuleExit:

// app/plugins/modular-vue.ts
import { installModularApp } from "@modular-vue/nuxt/runtime";
import buildRegistry from "~/modular/registry";

export default defineNuxtPlugin((nuxtApp) => {
  const registry = buildRegistry();
  const manifest = installModularApp(nuxtApp, registry, {
    parentRouteName: "app",
    authGuard: (to) => (to.meta.requiresAuth && !isLoggedIn() ? "/login" : true),
    onModuleExit: () => nuxtApp.$router.push("/"),
  });
  return { provide: { modular: manifest } };
});

installModularApp(nuxtApp, registry, options?):

  • takes nuxtApp (needs vueApp + $router — the structural NuxtAppLike interface a real NuxtApp satisfies),
  • grafts module routes onto nuxtApp.$router, installs the optional authGuard, and installs the manifest on nuxtApp.vueApp,
  • returns the ApplicationManifest (router, navigation, slots, modules, recalculateSlots, …).

Everything but router is forwarded to registry.resolve()router comes from nuxtApp.$router.

Plugin extensions survive (and thread themselves app-wide)

The returned manifest keeps its plugin extensions typed. A registry built with createRegistry({}).use(journeysPlugin()) hands back a manifest whose extensions.journeys — and the manifest.journeys convenience alias — is typed as JourneyRuntime, no cast. (installModularApp infers the extension map from the registry's resolve() return rather than the opaque plugin tuple, which can't be recovered by inference.)

You also usually don't need to touch that runtime by hand. Because the install calls resolve(), any plugin that implements the Vue appProvides hook — the journeys plugin does — is threaded app-wide via app.provide during nuxtApp.vueApp.use(manifest). So <JourneyHost> / <JourneyOutlet> and useJourneyContext() resolve the runtime from context with no <JourneyProvider> wrapper and no manual provideJourneyRuntime. (Call provideJourneyRuntime only for the escape-hatch cases it documents — a hand-built runtime with no plugin, or installing the same runtime on a second app.)

Typing manifest under defineNuxtPlugin

Returning { provide: { modular: manifest } } makes Nuxt infer the plugin's provide types from manifest. When the manifest carries a large plugin runtime (the journeys runtime is large), that structural inference can trip TypeScript's self-reference guard — TS7022, "referenced directly or indirectly in its own initializer." Annotate the binding to break the cycle; the precise type is nameable thanks to the extension threading above:

import type { ApplicationManifest } from "@modular-vue/nuxt/runtime";
import type { JourneyRuntime } from "@modular-vue/journeys";

export default defineNuxtPlugin((nuxtApp) => {
  const registry = buildRegistry();
  const manifest: ApplicationManifest<AppSlots, AppNavItem, { journeys: JourneyRuntime }> =
    installModularApp(nuxtApp, registry);
  return { provide: { modular: manifest } };
});

Routing: runtime addRoute and the first paint

Module routes are added at runtime, when the plugin runs, via router.addRoute(). They are available for client-side navigation immediately. But Nuxt resolves the initial route during the same startup pass, so for a module route to render on the first server-rendered paint (a hard load or refresh straight onto /billing), Nuxt's route table must already contain a match. Two ways to guarantee that:

  1. Declare a shell page and graft under it. Give Nuxt a real page — e.g. app/pages/[...modular].vue (a catch-all) or a named app/pages/app.vue — and pass its route name as parentRouteName. Nuxt's initial resolution matches the shell page; the grafted child renders inside its <NuxtPage> / <router-view>. This is the recommended pattern.
  2. Client-only modules. If a module's routes are only reached via in-app navigation (never a deep link on cold load), no shell page is needed — the runtime addRoute has already run by the time the user navigates.

This is the Nuxt analog of the note in the React/TanStack framework-mode guides that route shape is owned by the framework: here Nuxt owns initial route resolution, so a deep-linkable module subtree needs a page Nuxt knows about at build time to hang under.

Auth guards

Install the guard through the integration rather than a second beforeEach, so it's wired on the same router the modules graft onto. It reads to.meta — the vue-router channel modules populate via their RouteMeta convention (see Shell Patterns for Vue Router):

installModularApp(nuxtApp, registry, {
  authGuard: (to) => (to.meta.requiresAuth && !useAuth().isLoggedIn ? "/login" : true),
});

Nuxt's own defineNuxtRouteMiddleware still works alongside it; use whichever fits — the guard option exists so a shell that already models auth through module meta doesn't have to restate it as Nuxt middleware.

SSR and per-request state

Under SSR Nuxt builds a fresh app and router per request. Two consequences:

  • Build the registry per request. registry.resolve() is single-use and server state must not leak between requests, so call the factory inside the plugin (both options above do). A module-level singleton is safe only for a client-only app (ssr: false), where the plugin runs once.
  • Stores are per request too. Because the factory runs per request, the stores it creates are per request — no cross-request bleed of user/session state. Keep store creation inside the factory, not at module top level.

Dynamic slots

recalculateSlots() on the returned manifest re-evaluates dynamicSlots / slotFilter after a state change (login, role change, feature flag). Wire it to your store subscription as usual:

export default defineNuxtPlugin((nuxtApp) => {
  const registry = buildRegistry();
  const manifest = installModularApp(nuxtApp, registry);
  // e.g. re-run dynamic slots when auth changes on the client.
  if (import.meta.client) authStore.subscribe(manifest.recalculateSlots);
  return { provide: { modular: manifest } };
});

recalculateSlots is a no-op unless a module declared dynamicSlots or you passed a slotFilter, so the subscription is cheap to wire even when nothing is dynamic.

Nuxt layers: letting a consumer contribute modules

A Nuxt layer is published once and extendsed by many deployments. The interesting case is a layer that owns a base registry (its own first-party modules) but lets each consuming app add its own — the frontend analog of a backend plugin registry. This works, with two things to get right.

Use Option B, not the module. The zero-config module (Option A) resolves its registry: "~/modular/registry" path against the consumer's srcDir, the same ~/@ rebinding every Nuxt layer hits. So the layer's own base registry and the consumer's additions never compose through Option A. Instead the layer ships its own defineNuxtPlugin (Option B) that builds the base registry and installs it.

Expose a registration seam and mind plugin order. The layer keeps a module list its plugin reads at resolve time, and exports an addModule-style function for consumers:

// layer: app/modular/registry.ts
import { createRegistry } from "@modular-vue/runtime";
import type { AnyModuleDescriptor } from "@modular-vue/core";

const firstParty: readonly AnyModuleDescriptor[] = [
  /* the layer's own modules */
];
const contributed: AnyModuleDescriptor[] = [];

/** Consumers call this from their own plugin, before the layer resolves. */
export function registerAppModule(module: AnyModuleDescriptor): void {
  contributed.push(module);
}

export function buildRegistry() {
  const registry = createRegistry({});
  for (const m of [...firstParty, ...contributed]) registry.register(m);
  return registry;
}
// layer: app/plugins/modular.ts
import { installModularApp } from "@modular-vue/nuxt/runtime";
import { buildRegistry } from "~/modular/registry";

// `enforce: "post"` is the load-bearing detail — see below.
export default defineNuxtPlugin({
  name: "modular",
  enforce: "post",
  setup(nuxtApp) {
    const manifest = installModularApp(
      { vueApp: nuxtApp.vueApp, $router: nuxtApp.$router },
      buildRegistry(),
    );
    return { provide: { modular: manifest } };
  },
});
// consumer deployment: app/plugins/my-modules.ts (a normal, default-order plugin)
export default defineNuxtPlugin(() => {
  registerAppModule(myModule); // registerAppModule is auto-imported from the layer
});

Nuxt loads a layer's plugins before the consuming app's plugins within one enforce bucket, so a consumer registering from a default plugin would otherwise run after the layer already resolved, and its module would be missed. Marking the layer's install plugin enforce: "post" flips the order: the consumer's default-bucket registration runs first, then the layer resolves with everything present. So the rule for consumers is "register from a default (or pre) plugin," and the layer's install plugin is always post.

For an ssr: false layer this is the simple case: the plugin runs once on the client, so the module-level contributed array and a singleton registry are fine (the per-request-factory rule only bites under SSR). Registering the same id twice throws at resolve via duplicate-id validation, which is the guard you want.

This same registerAppModule seam is how a consumer lights up a backend-driven capability that the layer knows nothing about: the consumer registers a module contributing its own code-shipped components (as ComponentEntry items) to a shared slot, and a remote capability manifest then selects one by a string id at render time. See Pairing wire-safe manifests with code-shipped components for that join — the components ship as code through the seam above; only the selecting id crosses the wire.

The seam also carries the two selection-hosted UI surfaces with no extra wiring, because their hosts read the same slots context the layer already provides: a consumer module can contribute inspector panels (render-all, subject-keyed — subject-panels.md) or modal windows with framework-managed chrome behaviour (pick-one, state-keyed — overlay-host.md) to slots the layer's shell hosts with <PanelsOutlet> / <OverlayOutlet>. No installModularApp change, no manifest threading — the slots manifest is the manifest.

Rules of thumb

  • Registry as a factory, called in the plugin. This is the one rule that keeps SSR correct — per-request registry, per-request stores, single-use resolve() honored.
  • Deep-linkable modules need a Nuxt page to graft under. Use parentRouteName + a shell/catch-all page for anything reachable by cold load; skip it for navigation-only modules.
  • Reach for Option B when you need behavior. The module (Option A) carries only serializable config; auth guards, provider plugins, slot filters, and exit handlers live in your own defineNuxtPlugin.
  • Layers own the plugin; consumers register from a default plugin. A layer that lets consumers contribute modules ships its own Option-B plugin marked enforce: "post" and exports a registration seam; consumers add modules from a normal-order plugin. See Nuxt layers.

See also