Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/content/docs/1.getting-started/6.integrations/6.ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,46 @@ if (theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-s

### Icons Display

Unfortunately displaying icons with the SSR server of the Vue version is currently not supported. The icons will only be displayed after the application is initialized at the user's end.
Nuxt UI bundles the icons it uses into your build, so they render straight away during SSR and work fully offline without any request to the Iconify API. This requires the icon collection data to be installed locally.

Nuxt UI uses the `lucide` collection by default, so install it to embed its own icons:

::code-group{sync="pm"}

```bash [pnpm]
pnpm i @iconify-json/lucide
```

```bash [yarn]
yarn add @iconify-json/lucide
```

```bash [npm]
npm install @iconify-json/lucide
```

::

Once the collection is installed, Nuxt UI's own icons are embedded automatically. You can bundle your own icons on top of them through the `icon.clientBundle` option in your `vite.config.ts`:

```ts [vite.config.ts]
import ui from '@nuxt/ui/vite'

export default defineConfig({
plugins: [
ui({
icon: {
clientBundle: {
icons: ['lucide:heart', 'simple-icons:github']
}
}
})
]
})
```

::note
Use the `{collection}:{name}` form for collections with a dash in their name, like `material-symbols:menu`, and install each collection you reference with `@iconify-json/{collection_name}`.
::

Icons whose collection is not installed fall back to loading from the Iconify API at runtime. To opt out of bundling entirely, set `icon.clientBundle` to `false`.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@iconify/utils": "^3.1.3",
"@iconify/vue": "^5.0.1",
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
Expand Down Expand Up @@ -194,6 +195,7 @@
"vue-component-type-helpers": "^3.3.5"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.114",
"@nuxt/eslint-config": "^1.16.0",
"@nuxt/module-builder": "^1.0.2",
"@nuxt/test-utils": "^4.0.2",
Expand Down
1 change: 1 addition & 0 deletions playgrounds/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.114",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^7.3.5",
Expand Down
19 changes: 15 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 119 additions & 0 deletions src/plugins/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { UnpluginOptions } from 'unplugin'
import { getIconData } from '@iconify/utils'
import { loadCollectionFromFS } from '@iconify/utils/lib/loader/fs'
import { getClientBundleIcons, hasIconCollection, parseIconName } from '../utils/icons'
import type { NuxtUIOptions } from '../unplugin'

const VIRTUAL_ID = 'virtual:nuxt-ui-icons'

type IconData = NonNullable<ReturnType<typeof getIconData>>

/**
* Resolve the `{collection}:{name}` icons to embed: Nuxt UI's own defaults plus any the
* user lists in `icon.clientBundle.icons`, restricted to collections whose data is
* installed under `root` (others fall back to runtime loading instead of breaking).
*/
function resolveBundleNames(options: NuxtUIOptions, appConfig: Record<string, any>, root: string): string[] {
const isAvailable = (collection: string) => hasIconCollection(collection, [root])

// Nuxt UI's defaults — already gated to trusted, single-word collections.
const names = new Set(getClientBundleIcons(appConfig.ui?.icons, isAvailable))

// User additions can be from any collection, so long as its data is installed.
const clientBundle = options.icon?.clientBundle
if (clientBundle && Array.isArray(clientBundle.icons)) {
for (const icon of clientBundle.icons) {
const name = parseIconName(icon)
if (name && isAvailable(name.slice(0, name.indexOf(':')))) {
names.add(name)
}
}
}

return [...names]
}

/** Load the SVG data for every requested icon from its locally-installed collection. */
export async function loadIconsData(names: string[], cwd: string): Promise<Record<string, IconData>> {
const byCollection = new Map<string, Set<string>>()
for (const fullName of names) {
const colon = fullName.indexOf(':')
const collection = fullName.slice(0, colon)

let iconNames = byCollection.get(collection)
if (!iconNames) {
byCollection.set(collection, iconNames = new Set())
}
iconNames.add(fullName.slice(colon + 1))
}

const data: Record<string, IconData> = {}
for (const [collection, iconNames] of byCollection) {
// `loadCollectionFromFS` resolves `@iconify-json/<collection>` from `cwd` and falls
// back to the full `@iconify/json` package, so it loads from wherever the collection
// is installed in the project (or workspace) — never `process.cwd()`.
const iconSet = await loadCollectionFromFS(collection, false, '@iconify-json', cwd)
if (!iconSet) {
continue
}

for (const name of iconNames) {
const icon = getIconData(iconSet, name)
if (icon) {
data[`${collection}:${name}`] = icon
}
}
}

return data
}

/**
* Embed the icons Nuxt UI uses into the build so they render synchronously during SSR and
* fully offline, instead of being fetched from the Iconify API at runtime.
*
* `@iconify/vue` looks icons up from an in-memory store, so the generated
* `virtual:nuxt-ui-icons` module registers the bundled data via `addIcon` from a Vue
* plugin. The Vue plugin install runs on both server and client, which is what makes the
* icons available during SSR (see `PluginsPlugin`, which `app.use`s it).
*/
export default function IconsPlugin(options: NuxtUIOptions, appConfig: Record<string, any>) {
// `config.root` is the Vite equivalent of Nuxt's `rootDir`; resolving from it (not
// `process.cwd()`) is what keeps workspace/monorepo builds working (nuxt/icon#502).
let root = options.root || process.cwd()
let source: Promise<string> | undefined

async function generate(): Promise<string> {
if (options.icon?.clientBundle === false) {
return 'export default { install() {} }'
}

const data = await loadIconsData(resolveBundleNames(options, appConfig, root), root)

return [
`import { addIcon } from '@iconify/vue'`,
`const icons = ${JSON.stringify(data)}`,
`export default { install() { for (const name in icons) addIcon(name, icons[name]) } }`
].join('\n')
}

return {
name: 'nuxt:ui:icons',
enforce: 'pre',
resolveId(id) {
if (id === VIRTUAL_ID) {
return VIRTUAL_ID
}
},
loadInclude: id => id === VIRTUAL_ID,
load() {
source ||= generate()
return source
},
vite: {
configResolved(config) {
root = options.root || config.root || root
}
}
} satisfies UnpluginOptions
}
2 changes: 2 additions & 0 deletions src/plugins/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ export default function PluginsPlugin(options: NuxtUIOptions) {
})

return `
import __nuxt_ui_icons__ from "virtual:nuxt-ui-icons"
${plugins.map(p => `import ${genSafeVariableName(p)} from "${p}"`).join('\n')}
${proseImports.map(c => `import ${c.name} from "${c.path}"`).join('\n')}

export default {
install (app, pluginOptions = {}) {
app.use(__nuxt_ui_icons__, pluginOptions)
${plugins.map(p => ` app.use(${genSafeVariableName(p)}, pluginOptions)`).join('\n')}
${proseImports.map(c => ` app.component('${c.name}', ${c.name})`).join('\n')}
}
Expand Down
26 changes: 23 additions & 3 deletions src/unplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import AppConfigPlugin from './plugins/app-config'
import ComponentImportPlugin from './plugins/components'
import NuxtEnvironmentPlugin from './plugins/nuxt-environment'
import AutoImportPlugin from './plugins/auto-import'
import IconsPlugin from './plugins/icons'

import type { TVConfig } from './runtime/types/tv'

Expand All @@ -40,9 +41,25 @@ export interface NuxtUIOptions extends Omit<ModuleOptions, 'fonts' | 'colorMode'
dts?: boolean
ui?: AppConfigUI
/**
* Default props for the `Icon` component
* Default props for the `Icon` component, and build-time icon bundling.
*/
icon?: Pick<RuntimeOptions, 'customize' | 'size' | 'mode'>
icon?: Pick<RuntimeOptions, 'customize' | 'size' | 'mode'> & {
/**
* Embed the icons Nuxt UI uses into the build so they render during SSR and fully
* offline, instead of being fetched from the Iconify API at runtime. Enabled by
* default for Nuxt UI's own icons (when their collection is installed); set to
* `false` to opt out.
* @see https://ui.nuxt.com/docs/getting-started/integrations/ssr#icons-display
*/
clientBundle?: false | {
/**
* Extra icons to bundle, on top of Nuxt UI's defaults. Accepts `i-{collection}-{name}`
* or `{collection}:{name}` (use the colon form for multi-word collections, e.g.
* `material-symbols:menu`). Only bundled when the collection's data is installed.
*/
icons?: string[]
}
}
/**
* Enable or disable `@vueuse/core` color-mode integration
* @defaultValue `true`
Expand Down Expand Up @@ -96,13 +113,16 @@ export const NuxtUIPlugin = createUnplugin<NuxtUIOptions | undefined>((_options
options.theme = options.theme || {}
options.theme.colors = resolveColors(options.theme.colors)

const appConfig = defu({ ui: options.ui, colorMode: options.colorMode, icon: options.icon }, { ui: getDefaultConfig(options.theme) })
// `clientBundle` is a build-time concern, so keep it out of the runtime app config.
const { clientBundle, ...icon } = options.icon || {}
const appConfig = defu({ ui: options.ui, colorMode: options.colorMode, icon }, { ui: getDefaultConfig(options.theme) })

return [
NuxtEnvironmentPlugin(options),
ComponentImportPlugin(options, meta),
AutoImportPlugin(options, meta),
tailwind(),
IconsPlugin(options, appConfig),
PluginsPlugin(options),
TemplatePlugin(options, appConfig),
AppConfigPlugin(options, appConfig),
Expand Down
Loading