Introduction
Nuxt 3 + shadcn-nuxt is not just "pretty buttons on Vue". It's an approach where you own the UI component source code, get SSR/SSG/ISR out of the box, build type-safe features, and don't pay for magic in production. Below is a working configuration, architectural decisions, pitfalls, and practices that have survived production.
Scenario: building a SPA/SSR application on Nuxt 3 with consistent UI through shadcn (shadcn-nuxt), Tailwind, theming, proper accessibility, and with an eye on scale (pages, forms, complex overlays, tables, modals).
A small production story: we once "caught" a TTFB increase by double after an innocuous fix — added three useAsyncData without keys in different components. Deduplication didn't work, N+1 started. After aggregating fetching and cache on Nitro, we returned TTFB to normal. Moral: SSR is a backend with templates, not just "server-side rendering".
Why Nuxt 3 is the Base Choice
What gives real profit:
- Nitro server: unified runtime for SSR/SSG/ISR with adapters for Node, Vercel, Netlify, Cloudflare. In production — free migration between providers and fine-grained cache control through routeRules/headers.
- Vite: fast DX, HMR and quick bundling.
- Vue 3 + Suspense: streaming SSR, adequate async boundaries.
- Auto-import and file-based architecture: less boilerplate (but watch for collisions).
- Type safety: TS "out of the box", nuxt-typed-router for routes, zod/valibot for schemas.
At high loads it's critical:
- Remove N+1 in SSR: aggregate requests, use common useAsyncData key and cache at Nitro/Redis level.
- Control payload: experimental.payloadExtraction, transform in useAsyncData, serialize only needed fields.
- Enable ISR/HTML cache through routeRules and CDN, variant cache by cookie/locale when needed.
Practical nuances:
- On edge runtimes not all Node modules are available (crypto, fs). On Vercel/Cloudflare use Web Crypto/Web Streams or move heavy stuff to background workers/queues.
- Logging and tracing: request-id in event.context, pino with transport to stdout, OpenTelemetry — greatly saves time on debugging during degradations.
What is shadcn-nuxt and Why It's Better Than Large UI Libraries
shadcn-nuxt is a component generator: you copy the source code into your repository and own it.
- Full control over layout and styles. No vendor lock-in and "black box" behavior.
- Tailwind + CSS variables as design tokens: simple theming.
- A11y through Radix Primitives (radix-vue): focus management, portals, ARIA.
- Predictable props, readable code — easy to customize for your product.
Pitfalls:
- Portals/teleports of overlays (Dialog/Popover/Dropdown): z-index and stacking context. Common pain — modal under header. Fixed with unified z-index scale and absence of extra contexts (overflow/transform on parents).
- Order of @layer base/components/utilities in Tailwind. Wrong order breaks themes.
- Icons: import lucide-vue-next selectively. "Import all" — +200 KB unexpectedly.
Quick Start and Configuration
Creating a project:
npx nuxi@latest init my-project
cd my-project
npm i
Installing shadcn-nuxt and modules:
npm i shadcn-nuxt @nuxtjs/tailwindcss lucide-vue-next tailwindcss-animate
nuxt.config.ts (essentials):
export default defineNuxtConfig({
modules: [
'@nuxtjs/tailwindcss',
'shadcn-nuxt',
// 'nuxt-typed-router',
// '@nuxtjs/color-mode',
// '@nuxt/devtools',
],
shadcn: {
prefix: 'Ui',
componentDir: './components/ui',
},
experimental: {
payloadExtraction: true,
},
nitro: {
// preset: 'vercel-edge', // if you understand the limitations
},
routeRules: {
'/': { isr: 60 },
'/blog/**': { isr: 300 },
'/api/public/**': { cache: { maxAge: 60 } },
},
tailwindcss: { viewer: false },
})
components.json:
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "assets/css/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
Tailwind base theme:
/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root { /* light theme tokens */ }
.dark { /* dark theme tokens */ }
}
tailwind.config.js (main thing — content paths and theming):
module.exports = {
darkMode: ['class'],
content: ['./app.vue','./components/**/*.{vue,js,ts}','./pages/**/*.{vue,js,ts}','./layouts/**/*.{vue,js,ts}','./plugins/**/*.{js,ts}','./nuxt.config.{js,ts}'],
theme: { extend: { /* colors from CSS variables, radii */ } },
plugins: [require('tailwindcss-animate')],
}
Adding components selectively:
npx shadcn-vue@latest add button card input label dialog dropdown-menu
Tip: pin component template versions. Update consciously — sometimes classes/slots change.
Architecture and Project Organization
Structure:
components/
├── ui/ # generated by shadcn
├── partials/ # small reusable pieces
├── sections/ # page sections
└── widgets/ # composite widgets
lib/
├── utils/
└── validations/ # zod/valibot schemas
server/
├── api/ # h3 endpoints
└── services/ # external clients, DB
Auto-import with prefixes reduces collisions:
components: {
global: true,
dirs: [
{ path: '~/components/ui', prefix: 'Ui' },
{ path: '~/components/partials', prefix: 'P' },
{ path: '~/components/widgets', prefix: 'W' },
],
}
Composition over UI (wrap, don't fork base components):
<template>
<UiCard>
<UiCardHeader>
<UiCardTitle>{{ title }}</UiCardTitle>
<UiCardDescription v-if="description">{{ description }}</UiCardDescription>
</UiCardHeader>
<UiCardContent><slot /></UiCardContent>
<UiCardFooter class="flex gap-2 justify-end">
<UiButton variant="outline" @click="$emit('cancel')">Cancel</UiButton>
<UiButton @click="$emit('confirm')">Confirm</UiButton>
</UiCardFooter>
</UiCard>
</template>
<script setup lang="ts">
defineProps<{ title: string; description?: string }>()
defineEmits<{ cancel: []; confirm: [] }>()
</script>
A11y: Don't Skip It
- Focus in dialogs: on open — first interactive, on close — return. Test tab cycles.
- Validate contrast in CI (axe, pa11y). Playwright + @axe-core/playwright — must-have.
- Don't break keyboard navigation with custom keydown.
- Check that portal containers are accessible to screen readers, aria attributes in place.
In production, a11y regressions often come from overflow/z-index changes. Screenshot tests plus axe catch this before release.
Performance: What Actually Works
Hydration:
- Heavy widgets (tables, editors) — dynamic import + ClientOnly outside LCP.
- Popups/modals better keep mounted and "sleeping" — faster than mounting each time.
- Avoid reactivity transform — convenient, but gives hard-to-catch bugs, and officially not recommended.
Data fetching:
// pages/users.vue
const { data: users } = await useAsyncData('users:list', () =>
$fetch('/api/users', { query: { limit: 50 } }),
{
server: true,
transform: (raw) => raw.items.map(({ id, name }) => ({ id, name })), // cut unnecessary
}
)
Anti-patterns:
- Multiple useAsyncData without common keys — loss of deduplication, N+1.
- Fat payload — slow TTFB. Cut fields, enable payloadExtraction.
- SSR inside v-for calls API for each element — aggregate on server.
Cache and ISR:
- Marketing/blog: routeRules isr + Cache-Control on CDN.
- Expensive API cache in Redis. Inject client in event.context, use defineCachedEventHandler.
- Mandatory timeouts/retries on backend degradation. "It's usually fast" — bad strategy.
Bundle analysis:
- rollup-plugin-visualizer, vite-bundle-visualizer — check who brought +200 KB.
- Import icons by names, not wildcard.
Theming
- Tokens in :root and .dark. Change values — design changes, without hunting for classes.
- @nuxtjs/color-mode with mode: 'class' eliminates FOUC.
- Agree on z-index scale in advance (e.g., 10/50/100/1000 for overlays/toasts/modals/navigation).
Server-side and Integrations
Nitro is a full-fledged runtime:
// server/api/users.get.ts
import { z } from 'zod'
const Query = z.object({ limit: z.coerce.number().min(1).max(100).default(20) })
export default defineEventHandler(async (event) => {
const { limit } = Query.parse(getQuery(event))
// call service/DB
return { items: await listUsers({ limit }) }
})
Integrations in real projects:
- Separate backend: NestJS + Fastify for high-load API, Nuxt — as BFF or frontend. Prisma — quick start and migrations; TypeORM — if you need deep ORM magic. On serverless DB (Neon/PlanetScale) control pool and cold start, look towards Prisma Accelerate/Data Proxy.
- Redis for cache/sessions, BullMQ for queues. Removes request avalanche during peaks.
- AsyncLocalStorage (in Nest) and event.context (in Nitro) for request-scoped logs and tracing.
- Security: CSP and security headers in routeRules.headers, SameSite=Lax for BFF cookies, CSRF where state-changing forms are needed.
Typical production problems:
- Memory leaks from SDK and unclosed streams during SSR. Profile heap, look for hanging timers.
- Event loop blocking with synchronous crypto/zlib — move to worker threads/queues.
Type Safety and DX
- nuxt-typed-router for typed routes.
- Forms: zod/valibot + vee-validate or @vueuse/form. One schema for client and server.
- API type generation: openapi-typescript, graphql-codegen.
- useRuntimeConfig for secrets; validate env with Zod on startup.
Testing and Quality
- Unit: Vitest + Vue Test Utils.
- E2E: Playwright (screenshots + axe).
- Linting: ESLint + eslint-plugin-vue + typescript-eslint. Rules for circular dependencies.
- Perf: Lighthouse CI in PR, guard by bundle size.
Common Problems and Solutions
- shadcn components "broke" after Tailwind update:
Check @tailwind/@layer order.
- Regenerate components with CLI and check diff.
- Modals under header:
Any overflow/transform on parents? Teleport to body, set z-index scale.
- Auto-import chattering:
Prefixes + consistent names. Don't duplicate UiButton — make WButton wrapper with your logic.
- Deploy to edge:
Node specifics unavailable. Use Web equivalents or Node preset.
Conclusion
Nuxt 3 and shadcn-nuxt feel equally confident in MVPs and mature products:
- SSR/SSG/ISR for different traffic profiles.
- Controlled UI without vendor lock-in and with strong a11y.
- Type-safe DX without unnecessary boilerplate.
- Direct backend integration (NestJS/Fastify, Prisma/TypeORM, Redis, BullMQ).
If you keep performance, a11y, and data architecture in focus, the stack delivers fully: fast interface, predictable TTFB, and stability under load — without magic and surprises.

