Создание современного веб‑приложения с Nuxt 3 и shadcn-nuxt

Ларцев Дмитрий

Прагматичный гайд по сборке производительного и масштабируемого приложения на Nuxt 3 и shadcn-nuxt: архитектура, SSR/ISR, производительность, a11y, кеш и интеграции с backend.

Создание современного веб‑приложения с Nuxt 3 и shadcn-nuxt

Введение

Nuxt 3 + shadcn-nuxt — это не “красивые кнопки на Vue”. Это подход, где вы владеете исходниками UI‑компонентов, получаете SSR/SSG/ISR из коробки, строите типобезопасные фичи и не расплачиваетесь за магию на проде. Ниже — рабочая конфигурация, архитектурные решения, подводные камни и практики, которые пережили прод.

Сценарий: строим SPA/SSR‑приложение на Nuxt 3 с консистентным UI через shadcn (shadcn-nuxt), Tailwind, темизацией, нормальной доступностью, и прицелом на масштаб (страницы, формы, сложные оверлеи, таблицы, модалки).

Маленькая история с продакшена: мы однажды “поймали” рост TTFB вдвое после innocuous правки — добавили три useAsyncData без ключей в разных компонентах. Дедупликация не сработала, начались N+1. После агрегации фетчинга и кеша на Nitro вернули TTFB в норму. Мораль: SSR — это бэкенд с шаблоном, а не просто “рендер на сервере”.

Почему Nuxt 3 — базовый выбор

Что даёт реальный профит:

  • Nitro‑сервер: единый рантайм для SSR/SSG/ISR с адаптерами под Node, Vercel, Netlify, Cloudflare. На проде — свободная миграция провайдеров и тонкий контроль кэша через routeRules/headers.
  • Vite: быстрый DX, HMR и шустрый бандлинг.
  • Vue 3 + Suspense: потоковый SSR, адекватные async‑границы.
  • Автоимпорт и файловая архитектура: меньше бойлерплейта (но следите за коллизиями).
  • Типобезопасность: TS “из коробки”, nuxt-typed-router для маршрутов, zod/valibot для схем.

На высоких нагрузках критично:

  • Убирать N+1 в SSR: агрегируйте запросы, используйте общий ключ useAsyncData и кеш на уровне Nitro/Redis.
  • Контролировать payload: experimental.payloadExtraction, transform в useAsyncData, сериализуйте только нужные поля.
  • Включать ISR/HTML‑кеш через routeRules и CDN, вариантный кеш по cookie/locale когда нужно.

Практические нюансы:

  • На edge‑рантаймах не все Node‑модули доступны (crypto, fs). На Vercel/Cloudflare используйте Web Crypto/Web Streams или выносите тяжелые штуки в фоновые воркеры/очереди.
  • Логирование и трассировка: request‑id в event.context, pino с transport на stdout, OpenTelemetry — сильно экономит время на дебаг при деградациях.

Что такое shadcn-nuxt и чем он лучше больших UI‑библиотек

shadcn-nuxt — это генератор компонентов: вы копируете исходники в свой репозиторий и владеете ими.

  • Полный контроль верстки и стилей. Нет вендор‑локина и “black box” поведения.
  • Tailwind + CSS‑переменные как дизайн‑токены: простая темизация.
  • A11y через Radix Primitives (radix-vue): фокус‑менеджмент, порталы, ARIA.
  • Предсказуемые пропы, читаемый код — легко дорабатывать под продукт.

Подводные камни:

  • Порталы/телепорты оверлеев (Dialog/Popover/Dropdown): z‑index и stacking context. Частая боль — модалка под шапкой. Лечится единой шкалой z‑index и отсутствием лишних контекстов (overflow/transform на родителях).
  • Порядок @layer base/components/utilities в Tailwind. Неправильный порядок ломает темы.
  • Иконки: lucide‑vue‑next импортируйте точечно. “Импорт всего” — +200 КБ неожиданно.

Быстрый старт и конфигурация

Создание проекта:

npx nuxi@latest init my-project
cd my-project
npm i

Установка shadcn-nuxt и модулей:

npm i shadcn-nuxt @nuxtjs/tailwindcss lucide-vue-next tailwindcss-animate

nuxt.config.ts (выжимка):

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', // если точно понимаете ограничения
  },
  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 базовая тема:

/* assets/css/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root { /* токены светлой темы */ }
  .dark { /* токены темной темы */ }
}

tailwind.config.js (главное — content‑пути и темизация):

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: { /* цвета от CSS‑переменных, радиусы */ } },
  plugins: [require('tailwindcss-animate')],
}

Добавление компонентов точечно:

npx shadcn-vue@latest add button card input label dialog dropdown-menu

Совет: закрепляйте версии шаблонов компонентов. Обновляйте осознанно — иногда меняются классы/слоты.

Архитектура и организация проекта

Структура:

components/
├── ui/         # сгенерированные shadcn
├── partials/   # мелкие переиспользуемые куски
├── sections/   # секции страниц
└── widgets/    # композитные виджеты
lib/
├── utils/
└── validations/ # zod/valibot схемы
server/
├── api/        # h3‑эндпоинты
└── services/   # внешние клиенты, БД

Автоимпорт c префиксами снижает коллизии:

components: {
  global: true,
  dirs: [
    { path: '~/components/ui', prefix: 'Ui' },
    { path: '~/components/partials', prefix: 'P' },
    { path: '~/components/widgets', prefix: 'W' },
  ],
}

Композиция поверх UI (оборачивайте, не форкайте базовые):

<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')">Отмена</UiButton>
      <UiButton @click="$emit('confirm')">Подтвердить</UiButton>
    </UiCardFooter>
  </UiCard>
</template>
<script setup lang="ts">
defineProps<{ title: string; description?: string }>()
defineEmits<{ cancel: []; confirm: [] }>()
</script>

A11y: не пропускайте

  • Фокус в диалогах: по открытию — первый интерактивный, по закрытию — возврат. Тестируйте таб‑циклы.
  • Контраст валидируйте в CI (axe, pa11y). Playwright + @axe-core/playwright — must‑have.
  • Не ломайте клавиатурную навигацию кастомными keydown.
  • Проверяйте, что portal‑контейнеры доступны скринридерам, aria‑атрибуты на местах.

В проде регрессии по a11y часто приходят из правок overflow/z‑index. Скриншотные тесты плюс axe ловят это до релиза.

Производительность: что реально работает

Гидрация:

  • Тяжелые виджеты (таблицы, редакторы) — динамический импорт + ClientOnly за пределами LCP.
  • Попапы/модалки лучше держать смонтированными и “спящими” — быстрее, чем монтировать каждый раз.
  • Избегайте reactivity transform — удобно, но даёт трудноотлавливаемые баги, и официально не рекомендуется.

Фетчинг данных:

// 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 })), // режем лишнее
  }
)

Антипаттерны:

  • Несколько useAsyncData без общих ключей — потеря дедупликации, N+1.
  • Жирный payload — медленный TTFB. Режьте поля, включайте payloadExtraction.
  • SSR внутри v-for дергает API для каждого элемента — агрегируйте на сервере.

Кеш и ISR:

  • Маркетинг/блог: routeRules isr + Cache‑Control на CDN.
  • Дорогое API кешируйте в Redis. В event.context инжектируйте клиент, используйте defineCachedEventHandler.
  • Обязательно таймауты/ретраи при деградации бэка. “Оно обычно быстро” — плохая стратегия.

Анализ бандла:

  • rollup-plugin-visualizer, vite-bundle-visualizer — проверяйте, кто привёз +200 КБ.
  • Иконки импортируйте по именам, не wildcard.

Темизация

  • Токены в и .dark. Меняем значения — меняется дизайн, без охоты за классами.
  • @nuxtjs/color-mode с mode: 'class' избавит от FOUC.
  • Договоритесь о шкале z‑index заранее (например, 10/50/100/1000 для оверлеев/тостов/модалок/навигации).

Серверная часть и интеграции

Nitro — это полноценный рантайм:

// 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))
  // зовем сервис/БД
  return { items: await listUsers({ limit }) }
})

Интеграции в реальных проектах:

  • Отдельный бэкенд: NestJS + Fastify для high‑load API, Nuxt — как BFF или фронт. Prisma — быстрый старт и миграции; TypeORM — если нужна глубокая ORM‑магия. На serverless‑БД (Neon/PlanetScale) контролируйте пул и холодный старт, смотрите в сторону Prisma Accelerate/Data Proxy.
  • Redis для кеша/сессий, BullMQ для очередей. Снимает лавину запросов при пиках.
  • AsyncLocalStorage (в Nest) и event.context (в Nitro) для request‑scoped логов и трейсинга.
  • Безопасность: CSP и security‑заголовки в routeRules.headers, SameSite=Lax для BFF‑кук, CSRF там, где нужны state‑changing формы.

Типичные прод‑проблемы:

  • Утечки памяти из SDK и незакрытых стримов при SSR. Профилируйте heap, ищите hanging timers.
  • Блокировка event loop синхронным crypto/zlib — уносите в worker threads/очереди.

Типобезопасность и DX

  • nuxt-typed-router для типизированных маршрутов.
  • Формы: zod/valibot + vee-validate или @vueuse/form. Одна схема на клиенте и сервере.
  • Генерация типов для API: openapi-typescript, graphql-codegen.
  • useRuntimeConfig для секретов; валидируйте env Zod’ом на старте.

Тестирование и качество

  • Unit: Vitest + Vue Test Utils.
  • E2E: Playwright (скриншоты + axe).
  • Линтинг: ESLint + eslint-plugin-vue + typescript-eslint. Правила на циклические зависимости.
  • Перф: Lighthouse CI в PR, guard по bundle size.

Частые проблемы и решения

  • Компоненты shadcn “сломались” после обновления Tailwind:
    • Проверьте порядок @tailwind/@layer.
    • Перегенерируйте компоненты CLI и смотрите diff.
  • Модалки под хедером:
    • Нет ли у родителей overflow/transform? Телепортируйте в body, задайте шкалу z‑index.
  • Автоимпорт дребезжит:
    • Префиксы + единообразные имена. Не дублируйте UiButton — делайте обёртку WButton с вашей логикой.
  • Деплой на edge:
    • Node‑специфика недоступна. Используйте Web‑аналоги или Node‑preset.

Заключение

Nuxt 3 и shadcn-nuxt одинаково уверенно чувствуют себя в MVP и зрелых продуктах:

  • SSR/SSG/ISR под разные профили трафика.
  • Контролируемый UI без вендор‑локина и с сильным a11y.
  • Типобезопасный DX без лишнего бойлерплейта.
  • Прямая интеграция с бэком (NestJS/Fastify, Prisma/TypeORM, Redis, BullMQ).

Если держать в фокусе производительность, a11y и архитектуру данных, стек отрабатывает сполна: быстрый интерфейс, предсказуемый TTFB и стабильность под нагрузкой — без магии и сюрпризов.