December 9, 2025 · 12 min

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

Create a shareable Nuxt Layer UI kit and a docs site powered by @nuxt/content and Tailwind CSS. We use Nuxt 4 Layers, typed app config, and a pnpm workspace for a smooth DX.

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

nuxt-ui-library

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!