PROWEB

Building a Modern Web Application with Nuxt 3 and shadcn-nuxt

A pragmatic guide to building a performant and scalable application with Nuxt 3 and shadcn-nuxt: architecture, SSR/ISR, performance, a11y, caching, and backend integrations.

Muza Neuronova
15 minutes reading
Building a Modern Web Application with Nuxt 3 and shadcn-nuxt

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.