Blog
Jun 8, 2026
Setting Up Histoire in a Nuxt Monorepo + Bugs That I Encountered

Setting Up Histoire in a Nuxt Monorepo + Bugs That I Encountered

Sadeq Sheikhi

Sadeq Sheikhi

A complete guide to getting Histoire working inside a Nuxt + pnpm monorepo with i18n, Nuxt UI, and Iconify — including four crashes you will hit and exactly how to fix them.

Histoire is a story-based component browser for Vue 3 — think Storybook, but built specifically for the Vue ecosystem. If you are running a Nuxt app with @nuxtjs/i18n, @nuxt/ui, and Iconify, getting Histoire working is non-trivial. The @histoire/plugin-nuxt integration is still in beta and the crashes you will hit are not documented anywhere.

This post is a full record of every crash, why it happens, and exactly how to fix it. Skip to whichever bug you are hitting.

Setup Overview

Histoire is installed in the shared Nuxt layer of a pnpm monorepo — the package that every app extends. Any other package in the workspace can then drop *.story.vue files in its own directory and they get picked up automatically.

packages/
  nuxt-base      ← Histoire lives here
  ...other packages
apps/
  my-app
  ...other apps

Installation

pnpm add -D histoire @histoire/plugin-nuxt @histoire/plugin-vue --filter nuxt-base

All three pinned at 1.0.0-beta.1.

histoire.config.ts

import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'histoire'
import { HstNuxt } from '@histoire/plugin-nuxt'
import { HstVue } from '@histoire/plugin-vue'

const __dirname = dirname(fileURLToPath(import.meta.url))

export default defineConfig({
  plugins: [HstNuxt(), HstVue()],
  setupFile: './app/.histoire/setup.ts',

  // Absolute paths required — Vite rejects globs that resolve outside its root
  storyMatch: [
    resolve(__dirname, '../../packages/*/app/**/*.story.vue'),
    resolve(__dirname, '../../apps/**/*.story.vue'),
  ],

  tree: { file: 'title', order: 'asc' },
  theme: { title: 'My Platform', defaultColorScheme: 'auto' },
  vite: { server: { port: 7171 } },
})

The storyMatch glob lets every package drop stories without touching this config. The story's title prop drives the sidebar tree:

"my-package/Components/Button"  →  my-package > Components > Button

The npm script

"histoire": "HISTOIRE=true NODE_OPTIONS='--require ./histoire-node-setup.cjs' histoire dev --host"

Two things happening here that are not obvious — both exist because of bugs covered below.

  • HISTOIRE=true lets nuxt.config.ts disable features that crash in Histoire's Vite-only context (specifically @nuxt/fonts inside @nuxt/ui)
  • --require ./histoire-node-setup.cjs preloads a Node.js polyfill into every worker thread Histoire spawns

Bug #1 — BroadcastChannel crash in Node.js 24 worker threads

Symptom: Running histoire dev collects 0 stories. Nothing appears in the sidebar. No readable error in the terminal.

Root cause: @nuxtjs/i18n creates a native BroadcastChannel during Nuxt plugin initialisation. On Node.js ≥ 24, internal worker_threads deliver messages by calling channel.dispatchEvent(messageEvent) where messageEvent is created in a different V8 realm. The strict instanceof Event check added in Node 24 throws ERR_INVALID_ARG_TYPE, crashing the entire story collection phase silently.

Fix: Replace BroadcastChannel with a no-op EventTarget subclass before any module creates the real one. Load it via NODE_OPTIONS='--require' so it runs in every thread Histoire spawns.

// histoire-node-setup.cjs
'use strict'

if (
  typeof globalThis.BroadcastChannel !== 'undefined'
  && !globalThis.BroadcastChannel._histoirePatched
) {
  class SafeBroadcastChannel extends EventTarget {
    constructor(name) {
      super()
      Object.defineProperty(this, 'name', {
        value: String(name), writable: false, enumerable: true, configurable: false,
      })
      this.onmessage = null
      this.onmessageerror = null
    }
    postMessage() {}
    close() {}
  }
  SafeBroadcastChannel._histoirePatched = true
  globalThis.BroadcastChannel = SafeBroadcastChannel
}

Cross-tab locale broadcasting is meaningless inside a component browser so discarding all messages is fine.

Bug #2 — useNuxtApp().$config crash during story collection

Symptom: Stories are now collected (Bug #1 fixed) but Histoire's dev server crashes during the Nuxt plugin boot phase with Cannot read properties of undefined (reading 'config').

Root cause: @histoire/plugin-nuxt stubs useNuxtApp with a minimal object that only exposes runWithContext. @nuxtjs/i18n also accesses useNuxtApp().$config during app initialisation, which throws because the stub doesn't expose $config.

Fix: A Vite plugin that detects the stub by content fingerprint and replaces it with a richer version.

// In histoire.config.ts
function patchHistoireNuxtStub() {
  return {
    name: 'histoire:patch-nuxt-composables-stub',
    enforce: 'pre' as const,
    transform(code: string, _id: string) {
      if (
        code.length < 200
        && code.includes('runWithContext')
        && code.includes('useNuxtApp')
        && !code.includes('window.useNuxtApp')
      ) {
        return {
          code: `export const useNuxtApp = (...args) => {
  if (typeof window !== 'undefined' && typeof window.useNuxtApp === 'function' && !window.useNuxtApp._isHistoireStub) {
    return window.useNuxtApp(...args)
  }
  const config = (typeof window !== 'undefined' && window.__NUXT__?.config) || { public: {}, app: { baseURL: '/' } }
  return {
    runWithContext: async (fn) => await fn(),
    $config: config,
    versions: {},
    _id: 'histoire',
    $router: { currentRoute: { value: { fullPath: '/', name: 'index', params: {}, query: {}, matched: [] } } },
  }
}
useNuxtApp._isHistoireStub = true
`,
          map: null,
        }
      }
    },
  }
}

The stub is a virtual module served in-memory so resolveId/load cannot match it by path. The content fingerprint (code.length < 200 && includes('runWithContext')) is the only reliable interception method.

Bug #3 — Components using useI18n() render blank

Symptom: Simple components without Nuxt composables render correctly. Any component calling useI18n() produces a blank preview pane with no visible error.

Root cause: @histoire/plugin-nuxt boots the full Nuxt client entry in a separate Vue app so Nuxt plugins register their provides. Histoire renders stories in its own Vue app which has none of those provides. vue-i18n's useI18n() does more than call inject():

// Inside vue-i18n
const sym = instance.appContext.app.__VUE_I18N_SYMBOL__  // checked first
const i18n = inject(sym)                                  // then injected

It reads __VUE_I18N_SYMBOL__ directly from the app instance object — not from provides. If that property is missing on Histoire's app, useI18n() throws NOT_INSTALLED before reaching inject(). Vue silently catches setup errors during rendering — blank pane, no visible error.

Fix: Bridge both the provides and the i18n symbol in the Histoire setup file.

// app/.histoire/setup.ts
import { defineSetupVue3 } from '@histoire/plugin-vue'
import { addAPIProvider } from '@iconify/vue'

addAPIProvider('', { resources: ['https://api.iconify.design'] })

export const setupVue3 = defineSetupVue3(({ app }) => {
  const nuxtEl = document.getElementById('nuxt-test')
  const nuxtVueApp = (nuxtEl as any)?.__vue_app__
  if (!nuxtVueApp) return

  // Bridge all provides so inject()-based composables work
  if (nuxtVueApp._context?.provides) {
    Object.assign((app as any)._context.provides, nuxtVueApp._context.provides)
  }

  // vue-i18n checks an app-level property BEFORE calling inject()
  // Copy it or useI18n() throws NOT_INSTALLED even though provides are bridged
  for (const key of ['__VUE_I18N_SYMBOL__', '__VUE_I18N__']) {
    if ((nuxtVueApp as any)[key] !== undefined) {
      (app as any)[key] = (nuxtVueApp as any)[key]
    }
  }
})

Why does the provides bridge work for Pinia, useColorMode, and VueUse but not i18n? Because those composables call inject(symbol) directly. vue-i18n is the only one that also checks an app-level property as a guard first.

Bug #4 — Icons rendering as empty boxes

Symptom: Lucide icons and other Iconify icons are invisible in Histoire previews.

Root cause: In a real Nuxt dev server, @nuxt/icon serves SVGs through a local API route at /_nuxt_icon/. Histoire runs Vite without Nuxt's server layer so icon requests return 404 and silently produce empty elements.

Fix: Register Iconify's public CDN in the setup file before any component renders.

import { addAPIProvider } from '@iconify/vue'

addAPIProvider('', {
  resources: ['https://api.iconify.design'],
})

For icons that need to work offline, install the specific icon set:

pnpm add -D @iconify-json/lucide --filter nuxt-base

Writing stories

A minimal story:

<template>
  <Story title="my-package/Components/MyComponent" icon="i-lucide-box">
    <Variant title="Default">
      <div class="p-8 bg-(--ui-bg) rounded-lg">
        <MyComponent />
      </div>
    </Variant>
  </Story>
</template>

Always wrap variants in bg-(--ui-bg) — transparent components are invisible against Histoire's own background.

For interactive previews, Histoire supports live prop controls:

<script setup lang="ts">
function initState() {
  return reactive({ label: '', disabled: false })
}
</script>

<template>
  <Story title="my-package/Components/Button">
    <Variant title="Playground" :init-state="initState">
      <template #default="{ state }">
        <div class="p-8 bg-(--ui-bg) rounded-lg">
          <MyButton :label="state.label" :disabled="state.disabled" />
        </div>
      </template>
      <template #controls="{ state }">
        <HstText v-model="state.label" title="Label" />
        <HstCheckbox v-model="state.disabled" title="Disabled" />
      </template>
    </Variant>
  </Story>
</template>

File reference

FilePurpose
histoire.config.tsMain config — plugins, story glob, theme
histoire-node-setup.cjsBroadcastChannel polyfill for Node 24 workers
app/.histoire/setup.tsBrowser setup — provides bridge + Iconify CDN
app/components/*.story.vueStory files (auto-discovered)

Versions tested

PackageVersion
histoire1.0.0-beta.1
@histoire/plugin-nuxt1.0.0-beta.1
@histoire/plugin-vue1.0.0-beta.1
@nuxtjs/i18n10.4.0
@nuxt/ui^4.8.0
nuxt^4.4.4
Node.js≥ 24
Sadeq Sheikhi
Sadeq Sheikhi

Hi, Im a seniour software engineer building web platforms. I write about Nuxt, Typescript, DevOps, AI and engineering decisions behind real products, based on my real experience.

بیشتر بخوانید

Why I Chose Coolify to Self-Host Everything

A cost-conscious developer's case for self-hosting — and why Coolify is the tool that makes it sustainable for a single person.

How to Set Up Rate Limiting in Nuxt

A production-grade rate limiting setup for Nuxt — Redis-backed with in-memory fallback, named presets, and a 429 error page with a live countdown.