Build a Mini UI Library and Docs Site with Nuxt 4 + TypeScript + Tailwind

Ship a tiny, typed UI kit as a Nuxt Layer and a documentation site using @nuxt/content. This tutorial leans on Nuxt 4’s improved Layers and typed app.config for a clean DX.
Tags: Nuxt 4, Nuxt Layers, TypeScript, Tailwind CSS, @nuxt/content
Time to read: 12 min
Prerequisites
- Node.js 18.18+ (LTS recommended)
- pnpm 9+ (or adapt commands to npm/yarn)
- Basic Nuxt 3/4 and Tailwind familiarity
What’s new we’ll lean on:
- Nuxt 4 Layers workflow for shareable UI kits (extends)
- Typed app.config for theme tokens
- Content v2 with built‑in code highlighting
Project Structure
We’ll set up a pnpm workspace with:
- packages/ui: a Nuxt Layer that exports components, Tailwind config, and theme tokens
- apps/docs: a Nuxt app that extends the UI layer and renders docs via @nuxt/content
nuxt-ui-mini/
apps/
docs/
packages/
ui/
pnpm-workspace.yaml
package.json
1) Initialize the Workspace
mkdir nuxt-ui-mini && cd nuxt-ui-mini
# workspace manifest
cat > pnpm-workspace.yaml <<'EOF'
packages:
- "apps/*"
- "packages/*"
EOF
# root package
pnpm init -y
pnpm add -D typescript@latest @types/node@latest
2) Scaffold the UI Layer and Docs App
# UI layer
pnpm dlx nuxi@latest init -t layer packages/ui
# Docs app
pnpm dlx nuxi@latest init apps/docs
3) Install Tailwind in the UI Layer
We’ll add Tailwind only in the layer so any app extending it inherits styles.
pnpm -F ui add -D @nuxtjs/tailwindcss tailwindcss@^3.4 postcss autoprefixer @tailwindcss/typography
Update packages/ui/nuxt.config.ts:
// packages/ui/nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
tailwindcss: {
viewer: false
},
// expose components/composables from this layer
components: [
{ path: './components', pathPrefix: false }
]
})
Add Tailwind config in the layer:
// packages/ui/tailwind.config.ts
import type { Config } from 'tailwindcss'
export default <Partial<Config>>{
darkMode: 'class',
content: [
// Layer files (for local dev and when consumed via extends)
'./components/**/*.{vue,js,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./app.vue'
],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#4F46E5', // indigo-600
50: '#EEF2FF',
100: '#E0E7FF',
200: '#C7D2FE',
300: '#A5B4FC',
400: '#818CF8',
500: '#6366F1',
600: '#4F46E5',
700: '#4338CA',
800: '#3730A3',
900: '#312E81'
}
},
borderRadius: {
'md': '0.5rem'
}
}
},
plugins: [require('@tailwindcss/typography')]
}
4) Typed Theme Tokens with app.config
Expose simple theme tokens (primary color and rounding) and type them for auto-completion.
// packages/ui/app.config.ts
export default defineAppConfig({
ui: {
primary: 'indigo', // 'indigo' | 'emerald' | 'sky'
rounded: 'md' // 'none' | 'sm' | 'md' | 'lg' | 'full'
}
})
Type augmentation:
// packages/ui/types/app-config.d.ts
declare module 'nuxt/schema' {
interface AppConfigInput {
ui?: {
primary?: 'indigo' | 'emerald' | 'sky'
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
}
}
}
export {}
5) Build Your First Components
Create a typed button and card that use Tailwind and read theme tokens from app.config.
<!-- packages/ui/components/UiButton.vue -->
<script setup lang="ts">
const props = withDefaults(defineProps<{
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
type: 'button'
})
const emit = defineEmits<{ (e: 'click', ev: MouseEvent): void }>()
const { ui } = useAppConfig()
const palette = {
indigo: 'bg-indigo-600 hover:bg-indigo-700 focus-visible:ring-indigo-500 text-white',
emerald: 'bg-emerald-600 hover:bg-emerald-700 focus-visible:ring-emerald-500 text-white',
sky: 'bg-sky-600 hover:bg-sky-700 focus-visible:ring-sky-500 text-white'
} as const
const roundedMap = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full'
} as const
const sizeMap = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base'
} as const
const base =
'inline-flex items-center justify-center gap-2 font-medium transition ' +
'focus-visible:outline-none focus-visible:ring-2 disabled:opacity-60 disabled:cursor-not-allowed'
const variantClass = computed(() => {
if (props.variant === 'secondary') return 'bg-white text-gray-900 border border-gray-300 hover:bg-gray-50'
if (props.variant === 'ghost') return 'bg-transparent text-gray-900 hover:bg-gray-100'
// primary uses app.config palette
return palette[ui?.primary ?? 'indigo']
})
const radiusClass = computed(() => roundedMap[ui?.rounded ?? 'md'])
const sizeClass = computed(() => sizeMap[props.size])
function onClick(e: MouseEvent) {
if (props.disabled || props.loading) return
emit('click', e)
}
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="[base, variantClass, radiusClass, sizeClass]"
@click="onClick"
>
<svg v-if="loading" class="animate-spin -ml-0.5 h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<slot />
</button>
</template>
<!-- packages/ui/components/UiCard.vue -->
<script setup lang="ts">
const props = withDefaults(defineProps<{
padded?: boolean
as?: string
}>(), {
padded: true,
as: 'div'
})
</script>
<template>
<component
:is="as"
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-sm"
:class="padded ? 'p-6' : ''"
>
<slot />
</component>
</template>
A tiny header composed from these:
<!-- packages/ui/components/UiHeader.vue -->
<script setup lang="ts">
const { ui } = useAppConfig()
</script>
<template>
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="h-6 w-6 rounded-md" :class="{
'bg-indigo-600': ui?.primary === 'indigo',
'bg-emerald-600': ui?.primary === 'emerald',
'bg-sky-600': ui?.primary === 'sky'
}" />
<strong class="text-sm">Mini UI</strong>
</div>
<div class="flex items-center gap-2">
<UiButton variant="ghost" size="sm" @click="document.documentElement.classList.toggle('dark')">
Toggle Dark
</UiButton>
<a
href="https://github.com/"
target="_blank" rel="noreferrer"
class="text-gray-500 hover:text-gray-900 dark:hover:text-gray-200"
aria-label="GitHub"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A11.5 11.5 0 0 0 .5 12c0 5.07 3.29 9.36 7.86 10.88.58.1.79-.25.79-.55 0-.27-.01-1.17-.02-2.13-3.2.7-3.87-1.36-3.87-1.36-.53-1.36-1.3-1.72-1.3-1.72-1.06-.72.08-.71.08-.71 1.17.08 1.79 1.2 1.79 1.2 1.05 1.79 2.76 1.27 3.43.97.11-.76.41-1.27.75-1.56-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.27 1.2-3.07-.12-.29-.52-1.47.11-3.06 0 0 .98-.31 3.22 1.17a11.2 11.2 0 0 1 5.86 0c2.24-1.48 3.22-1.17 3.22-1.17.63 1.59.23 2.77.11 3.06.75.8 1.2 1.82 1.2 3.07 0 4.41-2.7 5.38-5.26 5.67.42.37.8 1.1.8 2.22 0 1.6-.01 2.89-.01 3.29 0 .3.21.66.8.55A11.51 11.51 0 0 0 23.5 12 11.5 11.5 0 0 0 12 .5z"/></svg>
</a>
</div>
</div>
</header>
</template>
6) Create the Docs App and Extend the Layer
Install Content in the docs app and extend the UI layer.
pnpm -F docs add @nuxt/content
apps/docs/nuxt.config.ts:
// apps/docs/nuxt.config.ts
export default defineNuxtConfig({
extends: ['../packages/ui'],
modules: ['@nuxt/content'],
content: {
highlight: {
theme: {
default: 'github-light',
dark: 'github-dark'
}
}
},
app: {
head: {
title: 'Mini UI Docs',
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }]
}
}
})
Add Tailwind config in docs so purge scans both the docs app and the layer:
// apps/docs/tailwind.config.ts
import type { Config } from 'tailwindcss'
import defaultTheme from 'tailwindcss/defaultTheme'
export default <Partial<Config>>{
darkMode: 'class',
content: [
'./components/**/*.{vue,js,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./app.vue',
'../packages/ui/components/**/*.{vue,js,ts}'
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans]
}
}
},
plugins: [require('@tailwindcss/typography')]
}
Optional: override theme tokens for the docs site:
// apps/docs/app.config.ts
export default defineAppConfig({
ui: {
primary: 'emerald',
rounded: 'lg'
}
})
Basic app shell using the UI components:
<!-- apps/docs/app.vue -->
<template>
<div class="min-h-dvh bg-gray-50 dark:bg-black text-gray-900 dark:text-gray-100">
<UiHeader />
<main class="mx-auto max-w-6xl px-4 py-8">
<slot />
</main>
</div>
</template>
7) Author Documentation Content
Create a simple navigation and docs pages with @nuxt/content.
<!-- apps/docs/content/1.getting-started.md -->
---
title: Getting Started
description: Install, extend, and ship your first component.
---
# Getting Started
This UI kit ships as a Nuxt Layer. In an app:
```ts
// nuxt.config.ts
export default defineNuxtConfig({
extends: ['@your-scope/mini-ui'] // or relative path while developing
})
Use a component:
<template>
<UiCard>
<h2 class="text-lg font-semibold mb-2">Hello</h2>
<UiButton @click="onClick">Click me</UiButton>
</UiCard>
</template>
<script setup lang="ts">
function onClick() {
alert('Clicked!')
}
</script>
```md
<!-- apps/docs/content/2.components/button.md -->
---
title: Button
description: Variants, sizes, and loading states.
---
# Button
The `UiButton` supports `variant`, `size`, and `loading`.
```vue
<template>
<div class="flex flex-wrap gap-3">
<UiButton>Primary</UiButton>
<UiButton variant="secondary">Secondary</UiButton>
<UiButton variant="ghost">Ghost</UiButton>
<UiButton size="sm">Small</UiButton>
<UiButton size="lg" :loading="true">Loading</UiButton>
</div>
</template>
Docs index page:
```vue
<!-- apps/docs/pages/index.vue -->
<script setup lang="ts">
const { data: nav } = await useAsyncData('docs-nav', () => queryContent().only(['_path', 'title']).find())
</script>
<template>
<UiCard>
<h1 class="text-2xl font-bold mb-4">Mini UI</h1>
<p class="text-gray-600 dark:text-gray-400 mb-6">
A tiny, typed Nuxt 4 UI kit using Tailwind. Explore the docs:
</p>
<ul class="list-disc pl-6 space-y-2">
<li v-for="p in nav" :key="p._path">
<NuxtLink class="text-brand-600 hover:underline" :to="p._path">{{ p.title }}</NuxtLink>
</li>
</ul>
</UiCard>
</template>
Optional route for any markdown page:
<!-- apps/docs/pages/[[...slug]].vue -->
<script setup lang="ts">
const route = useRoute()
const path = '/' + (Array.isArray(route.params.slug) ? route.params.slug.join('/') : (route.params.slug || ''))
</script>
<template>
<article class="prose prose-slate max-w-none dark:prose-invert">
<ContentDoc :path="path || '/getting-started'" />
</article>
</template>
8) Run It
Install deps and boot the docs app:
pnpm install
pnpm -F docs dev
Visit http://localhost:3000. You should see the header, a docs home page, and links to the content pages. Try toggling dark mode and changing tokens in apps/docs/app.config.ts.
9) Using the UI Layer in Other Apps
While developing, extend via a relative path:
// another-app/nuxt.config.ts
export default defineNuxtConfig({
extends: ['../nuxt-ui-mini/packages/ui']
})
Publishing as a package (optional):
- Set packages/ui/package.json with a name and files to publish.
- Ensure it includes components, app.config, tailwind config, and types.
Example minimal package.json:
{
"name": "@your-scope/mini-ui",
"version": "0.0.1",
"type": "module",
"publishConfig": { "access": "public" },
"files": [
"components",
"app.config.ts",
"types",
"nuxt.config.ts",
"tailwind.config.ts",
"README.md"
]
}
Consumers then:
export default defineNuxtConfig({
extends: ['@your-scope/mini-ui']
})
10) Tips and Troubleshooting
- Tailwind content scanning: ensure your docs app’s tailwind.config.ts includes the layer’s component paths so classes aren’t purged.
- Dynamic Tailwind classes: avoid string-building like bg-${color}-600. Map tokens to concrete class names instead (as shown in UiButton).
- Type safety for tokens: keep app-config.d.ts in the layer so IDEs guide allowed values.
- Local development: when extending via a relative path, restart dev server if you add new files in the layer so Nuxt picks them up.
Conclusion
You’ve built:
- A typed Nuxt 4 UI Layer with Tailwind and theme tokens
- A docs site powered by @nuxt/content that consumes your layer
- A developer-friendly workflow you can publish or reuse across apps
Next ideas:
- Add more primitives (Input, Badge, Switch)
- Provide a theme switcher with @nuxtjs/color-mode
- Wire up automated docs generation for props and slots
Happy building!





