HomeProjectsBlogContact
Download CV
Back to Blog

Nuxt 4: La Guía Definitiva y Completa (2026) - Español

Nuxt 4 fue lanzado oficialmente el 15 de julio de 2025. Esta es la guía más completa que encontrarás: desde instalación desde cero hasta patrones avanzados, buenas prácticas, configuración del proyecto y todo lo que cambió respecto a Nuxt 3. Si estás empezando en Nuxt 4, este es tu punto de partida.

Nuxt 4: La Guía Definitiva y Completa (2026) - Español

Table of Contents

  1. ¿Qué es Nuxt 4 y qué cambió?
  2. Instalación y Primer Proyecto
  3. La Nueva Estructura de Carpetas
  4. Configuración del Proyecto: nuxt.config.ts
  5. Routing y Páginas
  6. Layouts y Componentes
  7. Data Fetching: useFetch y useAsyncData
  8. Composables y Estado Global
  9. El Servidor: Nitro y API Routes
  10. La Carpeta shared/
  11. TypeScript en Nuxt 4
  12. Plugins y Middleware
  13. SEO y Head Management
  14. Nuxt Modules Esenciales
  15. Nuxt UI v4: El Design System Oficial
  16. Deployment y Estrategias de Rendering
  17. Migrar de Nuxt 3 a Nuxt 4
  18. Buenas Prácticas y Patrones Avanzados
  19. Nuxt DevTools
  20. Qué viene: Nuxt 5

1. ¿Qué es Nuxt 4 y qué cambió?

Nuxt es el meta-framework full-stack de Vue.js por excelencia. Piénsalo como Next.js pero para Vue: SSR, SSG, ISR, auto-imports, file-based routing, server API routes y mucho más, todo configurado de fábrica.

Nuxt 4, lanzado en julio de 2025, no es una revolución — es una evolución inteligente. A diferencia del salto de Nuxt 2 a 3 (que fue casi una reescritura completa), Nuxt 4 es una versión estable, enfocada en DX (Developer Experience) con mejoras concretas en:

┌─────────────────────────────────────────────────────────────────┐
│                    NUXT 4 — LO NUEVO                           │
│                                                                 │
│  🗂️  Nueva estructura app/ — código separado de config         │
│  🔄  Data Fetching mejorado — caché, deduplication, cleanup     │
│  🔧  TypeScript multi-proyecto — mejor autocomplete             │
│  ⚡  CLI más rápido — sockets, compile cache, fs.watch          │
│  🏷️  Nombres de componentes consistentes — DevTools + KeepAlive │
│  📄  Unhead v2 — mejor gestión del <head>                      │
│  🧹  Limpieza de APIs deprecadas                                │
│                                                                 │
│  Versión actual: 4.3.1 (noviembre 2025)                        │
│  Lanzamiento: 15 de julio de 2025                              │
└─────────────────────────────────────────────────────────────────┘

Nuxt 4 vs Nuxt 3 — Resumen visual

CaracterísticaNuxt 3Nuxt 4
Estructura de carpetasRaíz del proyectoapp/ (separado)
Data fetching keyPuede duplicar refsUna sola ref por key
TypeScript configUn tsconfig para todoMulti-proyecto separado
Valores por defecto (data)nullundefined
CLI comunicaciónPuertos de redSockets internos
Estilos inlineCSS global + componentesSolo componentes Vue
Nuxt 2 compat en @nuxt/kit❌ (eliminado)
Unheadv1v2

2. Instalación y Primer Proyecto

Prerrequisitos

# Necesitas:
# - Node.js 18.0.0 o superior (recomendado: Node 20 LTS)
# - Un gestor de paquetes: npm, yarn, pnpm o bun

node --version   # v20.x.x ✅
pnpm --version   # 9.x ✅ (recomendado)

Recomendación: Usa pnpm o bun. Son significativamente más rápidos que npm para proyectos Nuxt.

Crear un proyecto nuevo

# Con npx (npm)
npx nuxi@latest init mi-proyecto

# Con pnpm (recomendado)
pnpm dlx nuxi@latest init mi-proyecto

# Con bun
bunx nuxi@latest init mi-proyecto

El CLI te hará algunas preguntas:

? Which package manager would you like to use?
  ❯ pnpm
    npm
    yarn
    bun

? Would you like to initialize a git repository?
  ❯ Yes
    No

? Which modules would you like to use? (space to toggle)
  ◯ @nuxt/ui
  ◯ @nuxt/eslint
  ◯ @nuxt/fonts
  ◯ @nuxt/image

Estructura inicial generada

cd mi-proyecto
mi-proyecto/
├── app/                    ← ¡NUEVO en Nuxt 4! Todo tu código aquí
│   ├── assets/
│   ├── components/
│   ├── composables/
│   ├── layouts/
│   ├── middleware/
│   ├── pages/
│   ├── plugins/
│   ├── utils/
│   ├── app.vue             ← Entry point de la app
│   ├── app.config.ts       ← Config runtime (UI themes, etc.)
│   └── error.vue           ← Página de error personalizada
├── public/                 ← Archivos estáticos (favicon, robots.txt)
├── server/                 ← API routes, middleware de servidor
│   ├── api/
│   ├── middleware/
│   └── utils/
├── shared/                 ← Código compartido client/server
│   ├── types/
│   └── utils/
├── .nuxt/                  ← Generado automáticamente (no tocar)
├── nuxt.config.ts          ← Configuración principal
├── package.json
└── tsconfig.json

Arrancar el servidor de desarrollo

pnpm dev
# → http://localhost:3000

Verás algo como:

 NUXT  v4.3.1

  ➜ Local:    http://localhost:3000/
  ➜ Network:  http://192.168.1.x:3000/
  ➜ DevTools: http://localhost:3000/__nuxt_devtools__/

  ✔ Vite client built in 321ms
  ✔ Vite server built in 412ms
  ✔ Nuxt Nitro server built in 890ms

Comandos esenciales

pnpm dev          # Servidor de desarrollo
pnpm build        # Build para producción
pnpm preview      # Preview del build de producción
pnpm generate     # Generación estática (SSG)
pnpm typecheck    # Verificar tipos TypeScript

# Nuxi CLI utilities
npx nuxi add page about          # Crea app/pages/about.vue
npx nuxi add component Header    # Crea app/components/Header.vue
npx nuxi add composable useUser  # Crea app/composables/useUser.ts
npx nuxi add layout admin        # Crea app/layouts/admin.vue
npx nuxi add api users           # Crea server/api/users.ts
npx nuxi upgrade                 # Actualizar Nuxt

3. La Nueva Estructura de Carpetas

Este es el cambio más visible de Nuxt 4. Toda la lógica de la aplicación vive dentro de app/.

¿Por qué app/?

Problema en Nuxt 3:
mi-proyecto/
├── components/       ← Tu código
├── pages/            ← Tu código
├── node_modules/     ← Dependencias (100k+ archivos)
├── .git/             ← Git metadata
├── .nuxt/            ← Build cache

El file watcher (Chokidar) tenía que vigilar TODO el directorio raíz,
incluyendo node_modules y .git.
En proyectos grandes esto era lento, especialmente en Windows y Linux.

Solución en Nuxt 4:
mi-proyecto/
├── app/              ← EL WATCHER SOLO VIGILA ESTO ✅
│   ├── components/
│   └── pages/
├── node_modules/     ← Ignorado
├── .git/             ← Ignorado

Resultado: Startups de desarrollo más rápidos, mejor rendimiento del IDE, y separación conceptual clara entre código de app, código de servidor y configuración.

Cada carpeta y su propósito

app/ — Código del cliente (front-end)

app/
├── assets/           ← CSS global, imágenes, fuentes procesadas por Vite
├── components/       ← Componentes Vue (auto-importados)
├── composables/      ← Composables reutilizables (auto-importados)
├── layouts/          ← Layouts de página
├── middleware/       ← Middleware de navegación (cliente)
├── pages/            ← File-based routing
├── plugins/          ← Plugins que se ejecutan al iniciar la app
├── utils/            ← Funciones de utilidad (auto-importadas)
├── app.vue           ← Root component
├── app.config.ts     ← Config accesible en runtime
└── error.vue         ← Página de error personalizada

server/ — Código del servidor (Nitro)

server/
├── api/              ← Endpoints de API (/api/...)
├── middleware/       ← Middleware HTTP del servidor
├── plugins/          ← Plugins de Nitro
├── routes/           ← Rutas de servidor adicionales
└── utils/            ← Utilidades del servidor (auto-importadas)

shared/ — Código compartido (cliente + servidor)

shared/
├── types/            ← Tipos TypeScript compartidos
└── utils/            ← Funciones puras usables en ambos contextos

Raíz del proyecto — Solo configuración

nuxt.config.ts        ← Configuración de Nuxt
tsconfig.json         ← TypeScript config
package.json          ← Dependencias
.env                  ← Variables de entorno
public/               ← Archivos estáticos sin procesar
content/              ← Contenido para @nuxt/content (si lo usas)

Compatibilidad con Nuxt 3 (sin migrar)

¿Tienes un proyecto Nuxt 3 y no quieres migrar? No pasa nada. Nuxt 4 detecta automáticamente si tienes la estructura antigua y la respeta:

// nuxt.config.ts — Para mantener estructura Nuxt 3
export default defineNuxtConfig({
  // Si NO tienes app/, Nuxt usa la estructura raíz automáticamente
  // No necesitas hacer nada
})

4. Configuración del Proyecto: nuxt.config.ts

El archivo nuxt.config.ts es el corazón de la configuración. Con Nuxt 4, hay nuevas opciones y algunas que desaparecieron.

Configuración base completa

// nuxt.config.ts
export default defineNuxtConfig({
  // ┌─────────────────────────────────────────────┐
  // │  INFORMACIÓN DEL PROYECTO                   │
  // └─────────────────────────────────────────────┘
  
  // Compatibilidad con versiones
  compatibilityDate: '2025-07-15',  // Fecha de release de Nuxt 4

  // Habilitar DevTools
  devtools: { enabled: true },

  // ┌─────────────────────────────────────────────┐
  // │  MÓDULOS                                    │
  // └─────────────────────────────────────────────┘
  modules: [
    '@nuxt/ui',           // UI components
    '@nuxt/eslint',       // ESLint integrado
    '@nuxt/fonts',        // Gestión de fuentes
    '@nuxt/image',        // Optimización de imágenes
    '@nuxt/content',      // CMS basado en archivos
    '@nuxtjs/i18n',       // Internacionalización
    'nuxt-icon',          // Iconos
  ],

  // ┌─────────────────────────────────────────────┐
  // │  CSS                                        │
  // └─────────────────────────────────────────────┘
  css: ['~/assets/css/main.css'],

  // ┌─────────────────────────────────────────────┐
  // │  RUNTIME CONFIG (variables de entorno)      │
  // └─────────────────────────────────────────────┘
  runtimeConfig: {
    // Solo accesible en el servidor
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,
    stripeSecretKey: process.env.STRIPE_SECRET_KEY,

    // Accesible también en el cliente (public)
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
      siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
      stripePublicKey: process.env.STRIPE_PUBLIC_KEY,
    }
  },

  // ┌─────────────────────────────────────────────┐
  // │  APP CONFIG (no sensible, UI/theme)         │
  // └─────────────────────────────────────────────┘
  // Lo que va aquí también puede estar en app/app.config.ts
  // (app.config.ts es editable en runtime sin rebuild)

  // ┌─────────────────────────────────────────────┐
  // │  NITRO (servidor)                           │
  // └─────────────────────────────────────────────┘
  nitro: {
    preset: 'node-server', // 'vercel', 'cloudflare', 'netlify', etc.
    routeRules: {
      // Static pages (pre-renderizadas)
      '/': { prerender: true },
      '/about': { prerender: true },

      // ISR — regeneración incremental (1 hora)
      '/blog/**': { isr: 3600 },

      // SWR — stale while revalidate
      '/api/products/**': { swr: 600 },

      // Solo SSR
      '/dashboard/**': { ssr: true },

      // Deshabilitar SSR (SPA mode)
      '/app/**': { ssr: false },

      // Añadir headers
      '/api/**': {
        headers: { 'cache-control': 's-maxage=0' },
        cors: true,
      },
    },
  },

  // ┌─────────────────────────────────────────────┐
  // │  VITE                                       │
  // └─────────────────────────────────────────────┘
  vite: {
    define: {
      __APP_VERSION__: JSON.stringify(process.env.npm_package_version),
    },
    // Optimizaciones
    optimizeDeps: {
      include: ['lodash-es'],
    },
  },

  // ┌─────────────────────────────────────────────┐
  // │  EXPERIMENTAL (Nuxt 4.x)                   │
  // └─────────────────────────────────────────────┘
  experimental: {
    // Extrae handlers de useAsyncData en chunks separados
    // Reduce bundle size hasta un 39% en sitios estáticos
    asyncDataHandler: true,     // Disponible en Nuxt 4.2+

    // TypeScript plugin para mejor autocomplete (Nuxt 4.2+)
    typescriptPlugin: true,
    
    // Pagado por adelantado de rendering
    headNext: true,
  },

  // ┌─────────────────────────────────────────────┐
  // │  TYPESCRIPT                                 │
  // └─────────────────────────────────────────────┘
  typescript: {
    strict: true,         // Habilita strict mode
    typeCheck: true,      // Typechecking en build (lento pero seguro)
  },
})

Variables de entorno (.env)

# .env
# Variables NUXT_PUBLIC_* se exponen al cliente automáticamente
NUXT_PUBLIC_API_BASE=https://api.ejemplo.com
NUXT_PUBLIC_SITE_URL=https://ejemplo.com

# Variables sin NUXT_PUBLIC_ son solo del servidor
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=mi-secreto-super-seguro-aqui
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLIC_KEY=pk_live_...
// Usar en componentes (cliente + servidor)
const config = useRuntimeConfig()
console.log(config.public.apiBase) // Disponible siempre

// Solo en servidor (API routes, server middleware)
console.log(config.databaseUrl) // Undefined en el cliente ✅

5. Routing y Páginas

Nuxt usa file-based routing: la estructura de archivos en app/pages/ define automáticamente las rutas.

Rutas básicas

app/pages/
├── index.vue           →  /
├── about.vue           →  /about
├── contact.vue         →  /contact
└── blog/
    ├── index.vue       →  /blog
    └── [slug].vue      →  /blog/:slug (ruta dinámica)

Página básica

<!-- app/pages/index.vue -->
<template>
  <div>
    <h1>Bienvenido a mi app</h1>
    <p>Esta es la página principal.</p>

    <!-- NuxtLink es como RouterLink pero con prefetching automático -->
    <NuxtLink to="/about">Ir a About</NuxtLink>
  </div>
</template>

<script setup lang="ts">
// definePageMeta — configurar comportamiento de la página
definePageMeta({
  title: 'Inicio',
  layout: 'default',
  middleware: ['auth'],        // Middleware de autenticación
  keepalive: true,             // Mantener componente vivo en navegación
})
</script>

Rutas dinámicas

<!-- app/pages/blog/[slug].vue -->
<template>
  <article>
    <h1>{{ post?.title }}</h1>
    <p>{{ post?.body }}</p>
  </article>
</template>

<script setup lang="ts">
// Acceder a parámetros de ruta
const route = useRoute()
const slug = route.params.slug as string

// Fetch del post
const { data: post } = await useFetch(`/api/posts/${slug}`)

// SEO dinámico basado en datos del post
useSeoMeta({
  title: () => post.value?.title,
  description: () => post.value?.excerpt,
})
</script>

Rutas con parámetros múltiples y anidadas

app/pages/
├── users/
│   ├── index.vue           →  /users
│   ├── [id].vue            →  /users/:id
│   └── [id]/
│       ├── profile.vue     →  /users/:id/profile
│       └── settings.vue    →  /users/:id/settings
├── [...slug].vue           →  Catch-all (cualquier ruta no definida)
└── [[optional]].vue        →  Parámetro opcional
const router = useRouter()
const route = useRoute()

// Navegar
router.push('/dashboard')
router.push({ name: 'user-id', params: { id: '123' } })
router.replace('/login')
router.back()

// Obtener info de ruta actual
console.log(route.path)      // '/blog/mi-post'
console.log(route.params)    // { slug: 'mi-post' }
console.log(route.query)     // { page: '2' }
console.log(route.fullPath)  // '/blog/mi-post?page=2'

Route Rules en nuxt.config.ts (Híbrido SSR/SSG)

Una de las funciones más poderosas de Nuxt: mezclar estrategias de rendering por ruta.

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Marketing pages — pre-renderizadas en build time (SSG)
    '/': { prerender: true },
    '/about': { prerender: true },
    '/pricing': { prerender: true },

    // Blog — ISR (Incremental Static Regeneration) cada hora
    '/blog': { isr: 3600 },
    '/blog/**': { isr: 3600 },

    // Dashboard — siempre SSR (autenticado, personalizado)
    '/dashboard/**': { ssr: true },

    // Zona de app interna — solo cliente (sin SSR)
    '/editor/**': { ssr: false },

    // API con caché SWR
    '/api/products': { swr: 600 },
  }
})

6. Layouts y Componentes

Layouts

Los layouts envuelven tus páginas con estructura común (navbar, footer, sidebar).

<!-- app/layouts/default.vue -->
<template>
  <div class="min-h-screen flex flex-col">
    <!-- Navbar -->
    <AppNavbar />

    <!-- Contenido de la página -->
    <main class="flex-1 container mx-auto px-4 py-8">
      <slot />
    </main>

    <!-- Footer -->
    <AppFooter />
  </div>
</template>
<!-- app/layouts/admin.vue — Layout alternativo -->
<template>
  <div class="flex h-screen">
    <!-- Sidebar -->
    <AdminSidebar class="w-64 flex-shrink-0" />

    <!-- Contenido principal -->
    <div class="flex-1 overflow-auto">
      <AdminHeader />
      <main class="p-6">
        <slot />
      </main>
    </div>
  </div>
</template>
<!-- app/pages/dashboard/index.vue — Usar layout admin -->
<script setup lang="ts">
definePageMeta({
  layout: 'admin'     // Usa app/layouts/admin.vue
})
</script>

Componentes — Auto-imports

Nuxt importa automáticamente todos los componentes en app/components/. No necesitas import.

app/components/
├── AppNavbar.vue          →  <AppNavbar />
├── AppFooter.vue          →  <AppFooter />
├── blog/
│   ├── BlogCard.vue       →  <BlogCard />
│   └── BlogList.vue       →  <BlogList />
└── ui/
    ├── UiButton.vue       →  <UiButton />
    └── UiModal.vue        →  <UiModal />

Nuxt 4 mejora: Los nombres de componentes ahora son consistentes entre auto-imports, Vue DevTools, y <KeepAlive>. En Nuxt 3 había discrepancias — ahora <KeepAlive name="BlogCard"> funciona exactamente como esperas.

Componente ejemplo completo

<!-- app/components/blog/BlogCard.vue -->
<template>
  <article class="rounded-lg border p-6 hover:shadow-lg transition-shadow">
    <!-- Imagen con lazy loading -->
    <NuxtImg
      v-if="post.image"
      :src="post.image"
      :alt="post.title"
      class="w-full h-48 object-cover rounded mb-4"
      loading="lazy"
    />

    <!-- Categoría -->
    <span class="text-sm text-primary font-medium uppercase tracking-wide">
      {{ post.category }}
    </span>

    <!-- Título -->
    <h2 class="text-xl font-bold mt-2 mb-3">
      <NuxtLink :to="`/blog/${post.slug}`" class="hover:text-primary">
        {{ post.title }}
      </NuxtLink>
    </h2>

    <!-- Excerpt -->
    <p class="text-gray-600 text-sm line-clamp-3">
      {{ post.excerpt }}
    </p>

    <!-- Footer del card -->
    <div class="flex items-center justify-between mt-4">
      <div class="flex items-center gap-2">
        <img
          :src="post.author.avatar"
          :alt="post.author.name"
          class="w-8 h-8 rounded-full"
        >
        <span class="text-sm text-gray-700">{{ post.author.name }}</span>
      </div>
      <time class="text-sm text-gray-500" :datetime="post.publishedAt">
        {{ formatDate(post.publishedAt) }}
      </time>
    </div>
  </article>
</template>

<script setup lang="ts">
// Definir props con TypeScript
interface Post {
  slug: string
  title: string
  excerpt: string
  image?: string
  category: string
  publishedAt: string
  author: {
    name: string
    avatar: string
  }
}

const props = defineProps<{
  post: Post
}>()

// Función de utilidad
function formatDate(dateString: string): string {
  return new Intl.DateTimeFormat('es-ES', {
    day: 'numeric',
    month: 'long',
    year: 'numeric'
  }).format(new Date(dateString))
}
</script>

Componentes con <KeepAlive> (Nuxt 4 mejorado)

<!-- app/app.vue -->
<template>
  <NuxtLayout>
    <!-- Mantiene el estado del componente al navegar entre páginas -->
    <NuxtPage keepalive />
  </NuxtLayout>
</template>
<!-- O selectivo por componente -->
<template>
  <KeepAlive include="BlogList,ProductGrid">
    <component :is="currentComponent" />
  </KeepAlive>
</template>

7. Data Fetching: useFetch y useAsyncData

Este es uno de los cambios más importantes de Nuxt 4. El sistema de data fetching fue completamente revisado.

El problema que solucionó Nuxt 4

// ❌ Problema en Nuxt 3:
// Dos componentes usando la misma key creaban dos refs SEPARADAS
// → Datos duplicados, inconsistencia, bugs sutiles

// Componente A
const { data: user } = useAsyncData('user-profile', () => fetchUser())

// Componente B (mismo key!)
const { data: user } = useAsyncData('user-profile', () => fetchUser())
// ← Creaba un ref NUEVO, independiente del anterior

// ✅ Solución en Nuxt 4:
// Mismo key = MISMO reactive ref compartido
// No hay duplicación de requests ni inconsistencia

useFetch — Para requests HTTP simples

// app/pages/blog/index.vue
<script setup lang="ts">
// Fetching simple — Nuxt deduplica si se usa en SSR + cliente
const { data: posts, status, error, refresh } = await useFetch('/api/posts', {
  // Query params reactivos
  query: {
    page: 1,
    limit: 10,
    category: 'tech'
  },

  // Transformar respuesta
  transform: (data) => data.items,

  // Caché
  getCachedData(key, nuxtApp) {
    // Usa caché si los datos tienen menos de 5 minutos
    const cached = nuxtApp.payload.data[key]
    if (!cached) return undefined

    const expiry = 5 * 60 * 1000
    if (Date.now() - cached._timestamp > expiry) return undefined

    return cached
  },

  // Watch — refetch cuando cambia un valor
  watch: [selectedCategory],

  // Server-only (no re-fetch en cliente)
  server: true,
  lazy: false,
})
</script>

useAsyncData — Para lógica de fetching personalizada

<script setup lang="ts">
const route = useRoute()

// Fetch con lógica personalizada
const { data: post, status } = await useAsyncData(
  // Key único — crucial para caché y deduplicación
  `post-${route.params.slug}`,

  // Fetcher — cualquier función async
  async () => {
    const post = await $fetch(`/api/posts/${route.params.slug}`)
    // Puedes hacer transformaciones aquí
    return {
      ...post,
      readTime: calculateReadTime(post.content)
    }
  },

  // Opciones
  {
    // En Nuxt 4: default es undefined (no null como en Nuxt 3)
    default: () => null,

    // Watch para refetch automático
    watch: [() => route.params.slug],

    // Nuxt 4: getCachedData recibe contexto de por qué se re-fetch
    getCachedData(key, nuxtApp, ctx) {
      // ctx.cause puede ser: 'initial' | 'refresh' | 'watch' | 'navigate'
      if (ctx?.cause === 'refresh') return undefined // No caché en refresh manual
      return nuxtApp.payload.data[key]
    }
  }
)
</script>

$fetch — Fetch manual (sin caché ni SSR automático)

<script setup lang="ts">
// Para mutations, no para data inicial de página
async function submitForm(formData: FormData) {
  try {
    const response = await $fetch('/api/posts', {
      method: 'POST',
      body: formData,
    })
    navigateTo(`/blog/${response.slug}`)
  } catch (error) {
    // Manejo de error
    console.error(error)
  }
}
</script>

Abort Control (Nuevo en Nuxt 4.2)

// Cancelar requests de data fetching
const { data, refresh } = useAsyncData('search', () =>
  $fetch(`/api/search?q=${searchQuery.value}`)
)

// El AbortController se maneja automáticamente cuando:
// - El componente se desmonta
// - Se llama refresh() antes de que termine la request anterior
// - La key cambia (con watch)

// Control manual:
const controller = new AbortController()

const { data } = await useFetch('/api/large-dataset', {
  signal: controller.signal
})

// Cancelar desde fuera
function cancelRequest() {
  controller.abort()
}

Patrones de data fetching comunes

<!-- Patrón: Lista con paginación reactiva -->
<template>
  <div>
    <div v-if="status === 'pending'">Cargando...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <div v-else>
      <ProductCard v-for="product in data?.items" :key="product.id" :product="product" />
      <Pagination v-model="page" :total="data?.total" :per-page="perPage" />
    </div>
  </div>
</template>

<script setup lang="ts">
const page = ref(1)
const perPage = ref(12)

// Refetch automático cuando cambia la página
const { data, status, error } = await useAsyncData(
  'products',
  () => $fetch('/api/products', {
    query: { page: page.value, perPage: perPage.value }
  }),
  { watch: [page, perPage] }
)
</script>

8. Composables y Estado Global

Los composables son funciones reutilizables que encapsulan lógica de Vue (Composition API).

useState — Estado global simple

// app/composables/useTheme.ts
export const useTheme = () => {
  // useState es como ref() pero compartido entre todos los componentes
  // y persistido en SSR (hydration-safe)
  const theme = useState<'light' | 'dark'>('theme', () => 'light')

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  return { theme, toggleTheme }
}
<!-- Usar en cualquier componente — mismo estado compartido -->
<script setup lang="ts">
const { theme, toggleTheme } = useTheme()
</script>

<template>
  <button @click="toggleTheme">
    Tema actual: {{ theme }}
  </button>
</template>

Composable con data fetching

// app/composables/useUser.ts
export const useUser = () => {
  const { data: user, status, refresh } = useAsyncData(
    'current-user',
    () => $fetch('/api/auth/me'),
    { server: true }
  )

  const isLoggedIn = computed(() => !!user.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }

  return {
    user,
    isLoggedIn,
    isAdmin,
    status,
    refresh,
    logout,
  }
}

Composable con persistencia en localStorage

// app/composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const data = useState<T>(key, () => {
    // Solo en cliente (window no existe en servidor)
    if (import.meta.client) {
      const stored = localStorage.getItem(key)
      return stored ? JSON.parse(stored) : defaultValue
    }
    return defaultValue
  })

  // Watch para persistir cambios
  watch(data, (newValue) => {
    if (import.meta.client) {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  }, { deep: true })

  return data
}

// Uso:
const cart = useLocalStorage('cart', { items: [], total: 0 })

9. El Servidor: Nitro y API Routes

Nitro es el motor del servidor de Nuxt. Escribe API routes con TypeScript nativo, zero-config.

API Route básica

// server/api/hello.ts
export default defineEventHandler((event) => {
  return {
    message: 'Hello from Nuxt 4!',
    timestamp: new Date().toISOString()
  }
})
// → GET /api/hello

API Route con método HTTP específico

// server/api/posts/index.get.ts  → GET /api/posts
// server/api/posts/index.post.ts → POST /api/posts
// server/api/posts/[id].put.ts   → PUT /api/posts/:id
// server/api/posts/[id].delete.ts → DELETE /api/posts/:id

// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = Number(query.limit) || 10

  // Acceder a config del servidor
  const config = useRuntimeConfig(event)

  // Ejemplo con base de datos (cualquier ORM/driver)
  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { publishedAt: 'desc' }
  })

  return {
    items: posts,
    total: await db.post.count(),
    page,
    limit
  }
})
// server/api/posts/index.post.ts
export default defineEventHandler(async (event) => {
  // Validar que el usuario está autenticado
  const session = await getUserSession(event)
  if (!session?.user) {
    throw createError({
      statusCode: 401,
      statusMessage: 'No autorizado'
    })
  }

  // Leer y validar el body
  const body = await readBody(event)

  if (!body.title || !body.content) {
    throw createError({
      statusCode: 400,
      statusMessage: 'El título y contenido son requeridos'
    })
  }

  // Crear el post
  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: session.user.id,
      slug: slugify(body.title),
    }
  })

  // Respuesta con status 201
  setResponseStatus(event, 201)
  return post
})

Middleware del servidor

// server/middleware/auth.ts
// Se ejecuta en TODAS las requests

export default defineEventHandler(async (event) => {
  const url = getRequestURL(event)

  // Solo aplicar a rutas /api/admin/*
  if (!url.pathname.startsWith('/api/admin')) return

  // Verificar token
  const token = getHeader(event, 'authorization')?.replace('Bearer ', '')

  if (!token) {
    throw createError({ statusCode: 401, statusMessage: 'Token requerido' })
  }

  try {
    const user = await verifyJWT(token)
    event.context.user = user // Disponible en todos los handlers posteriores
  } catch {
    throw createError({ statusCode: 401, statusMessage: 'Token inválido' })
  }
})

Server Utils (auto-importadas)

// server/utils/db.ts
// Exportar singleton de base de datos

import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from '../db/schema'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

export const db = drizzle(pool, { schema })

// Disponible en todos los archivos de server/ sin importar
// server/api/posts.get.ts puede usar db directamente
// server/utils/email.ts
export async function sendEmail(to: string, subject: string, html: string) {
  // Implementación con Resend, Nodemailer, etc.
}

// server/utils/slugify.ts
export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

10. La Carpeta shared/

Una de las adiciones más útiles de Nuxt reciente. La carpeta shared/ contiene código que puede usarse tanto en el cliente (app/) como en el servidor (server/).

¿Para qué usar shared/?

shared/
├── types/              ← Tipos TypeScript compartidos
│   ├── index.ts
│   ├── user.ts
│   └── product.ts
└── utils/              ← Funciones puras sin side effects
    ├── format.ts
    ├── validation.ts
    └── constants.ts

Tipos compartidos

// shared/types/user.ts
export interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user' | 'moderator'
  avatar?: string
  createdAt: string
}

export interface UserProfile extends User {
  bio?: string
  website?: string
  social: {
    twitter?: string
    github?: string
    linkedin?: string
  }
}
// shared/types/api.ts
export interface ApiResponse<T> {
  data: T
  message?: string
  meta?: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

export interface ApiError {
  statusCode: number
  statusMessage: string
  data?: unknown
}

Utilidades compartidas

// shared/utils/format.ts
// ✅ Funciones puras — sin acceso a APIs de browser ni Node

export function formatPrice(
  amount: number,
  currency: string = 'EUR',
  locale: string = 'es-ES'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount)
}

export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text
  return text.slice(0, maxLength - 3) + '...'
}

export function formatDate(
  date: string | Date,
  locale: string = 'es-ES'
): string {
  return new Intl.DateTimeFormat(locale, {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
  }).format(new Date(date))
}
// shared/utils/validation.ts
export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

export function isValidUrl(url: string): boolean {
  try {
    new URL(url)
    return true
  } catch {
    return false
  }
}

export function validatePassword(password: string): {
  valid: boolean
  errors: string[]
} {
  const errors: string[] = []

  if (password.length < 8) errors.push('Mínimo 8 caracteres')
  if (!/[A-Z]/.test(password)) errors.push('Al menos una mayúscula')
  if (!/[0-9]/.test(password)) errors.push('Al menos un número')

  return { valid: errors.length === 0, errors }
}
// Usar en componentes (app/)
const { formatPrice, truncate } = useUtils() // Auto-importado desde shared/utils
// O directamente:
const price = formatPrice(29.99) // También auto-importado

// Usar en server (server/api/)
export default defineEventHandler(() => {
  return { price: formatPrice(29.99) } // Mismo shared/utils
})

11. TypeScript en Nuxt 4

Nuxt 4 trae la mejor experiencia TypeScript de cualquier versión.

Multi-proyecto TypeScript

Nuxt 4 genera proyectos TypeScript separados para cada contexto:

.nuxt/
├── tsconfig.app.json      ← Solo para app/
├── tsconfig.server.json   ← Solo para server/
├── tsconfig.shared.json   ← Solo para shared/
└── tsconfig.json          ← Root (incluye todos)

Esto significa:

  • Autocompletado correcto en cada contexto
  • No puedes accidentalmente usar window en código de servidor
  • No puedes usar variables solo-servidor en componentes cliente
  • Errores de tipo más precisos y útiles

tsconfig.json (Nuxt 4 lo genera automáticamente)

// tsconfig.json — Solo necesitas este archivo
{
  "extends": "./.nuxt/tsconfig.json"
}

¡Eso es todo! Nuxt genera el resto.

Tipos automáticos de Nuxt

// Nuxt genera automáticamente tipos para:

// 1. Rutas de tu app (type-safe routing)
const router = useRouter()
router.push('/blog/mi-post')          // ✅ Autocompletado de rutas
router.push('/ruta-que-no-existe')    // ❌ Error de TypeScript

// 2. API routes
const { data } = await useFetch('/api/posts')
// data.value.items → TypeScript conoce la estructura

// 3. Componentes auto-importados
// Los componentes en app/components/ tienen tipos automáticos

// 4. Composables auto-importados
// Los composables en app/composables/ tienen tipos automáticos

TypeScript Plugin (Nuxt 4.2 — Experimental)

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    typescriptPlugin: true  // Mejor autocomplete, más rápido
  }
})

Con el plugin activo, el language server de TypeScript usa un plugin de Nuxt para proporcionar información de tipos directamente integrada en tu editor, sin requerir el paso de generación de tipos.

Tipado de composables

// app/composables/useCart.ts
interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
  image?: string
}

interface CartState {
  items: CartItem[]
  coupon?: string
}

export function useCart() {
  const cart = useState<CartState>('cart', () => ({
    items: [],
    coupon: undefined
  }))

  const total = computed(() =>
    cart.value.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  const itemCount = computed(() =>
    cart.value.items.reduce((sum, item) => sum + item.quantity, 0)
  )

  function addItem(item: Omit<CartItem, 'quantity'>) {
    const existing = cart.value.items.find(i => i.id === item.id)
    if (existing) {
      existing.quantity++
    } else {
      cart.value.items.push({ ...item, quantity: 1 })
    }
  }

  function removeItem(id: string) {
    cart.value.items = cart.value.items.filter(i => i.id !== id)
  }

  function clearCart() {
    cart.value = { items: [], coupon: undefined }
  }

  return {
    cart: readonly(cart),
    total,
    itemCount,
    addItem,
    removeItem,
    clearCart,
  }
}

12. Plugins y Middleware

Plugins

Los plugins se ejecutan cuando la aplicación arranca, en servidor y/o cliente.

// app/plugins/analytics.client.ts
// El sufijo .client.ts → Solo se ejecuta en el navegador

export default defineNuxtPlugin(() => {
  // Inicializar analytics
  const router = useRouter()

  router.afterEach((to) => {
    // Track page view
    if (typeof gtag !== 'undefined') {
      gtag('event', 'page_view', {
        page_path: to.fullPath
      })
    }
  })
})
// app/plugins/api.ts
// Sin sufijo → Ejecuta en cliente Y servidor

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  // Crear instancia de $fetch con configuración base
  const api = $fetch.create({
    baseURL: config.public.apiBase,
    headers: {
      'Content-Type': 'application/json',
    },
    onRequest({ options }) {
      // Añadir token si existe
      const token = useCookie('auth-token').value
      if (token) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token}`
        }
      }
    },
    onResponseError({ response }) {
      if (response.status === 401) {
        navigateTo('/login')
      }
    }
  })

  // Proveer el cliente a toda la app
  return {
    provide: {
      api  // Accesible como this.$api o useNuxtApp().$api
    }
  }
})

Middleware de navegación

// app/middleware/auth.ts
// Se ejecuta antes de cada navegación a rutas que lo requieran

export default defineNuxtRouteMiddleware((to, from) => {
  const { isLoggedIn } = useUser()

  if (!isLoggedIn.value) {
    // Guardar destino original para redirect después de login
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  }
})
// app/middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
  const { isAdmin, isLoggedIn } = useUser()

  if (!isLoggedIn.value) return navigateTo('/login')
  if (!isAdmin.value) {
    throw createError({
      statusCode: 403,
      statusMessage: 'No tienes permiso para acceder a esta página'
    })
  }
})
<!-- Aplicar middleware a una página -->
<script setup lang="ts">
definePageMeta({
  middleware: ['auth', 'admin']  // Múltiples middleware en orden
})
</script>
// Middleware global (se ejecuta en TODAS las rutas)
// Nombrar: app/middleware/global-analytics.global.ts
// El sufijo .global.ts lo hace global automáticamente

13. SEO y Head Management

Nuxt 4 usa Unhead v2 para gestión del <head>. Mejor rendimiento y API más limpia.

useSeoMeta — La forma recomendada

<script setup lang="ts">
// useSeoMeta — tipado completo, sin typos posibles
useSeoMeta({
  // Básicos
  title: 'Mi página increíble',
  description: 'La mejor descripción del mundo para SEO',
  robots: 'index, follow',

  // Open Graph (Facebook, LinkedIn, WhatsApp)
  ogTitle: 'Mi página increíble',
  ogDescription: 'La mejor descripción del mundo para SEO',
  ogImage: 'https://ejemplo.com/og-image.jpg',
  ogImageWidth: 1200,
  ogImageHeight: 630,
  ogType: 'website',
  ogUrl: 'https://ejemplo.com/mi-pagina',
  ogSiteName: 'Mi Sitio Web',

  // Twitter / X
  twitterCard: 'summary_large_image',
  twitterSite: '@mitwitter',
  twitterCreator: '@mitwitter',
  twitterTitle: 'Mi página increíble',
  twitterDescription: 'La mejor descripción del mundo',
  twitterImage: 'https://ejemplo.com/twitter-image.jpg',
})
</script>

SEO dinámico para páginas de contenido

<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)

// SEO dinámico basado en el post — reactivo
useSeoMeta({
  title: () => `${post.value?.title} | Mi Blog`,
  description: () => post.value?.excerpt,
  ogTitle: () => post.value?.title,
  ogDescription: () => post.value?.excerpt,
  ogImage: () => post.value?.coverImage,
  ogType: 'article',

  // Article-specific Open Graph
  articlePublishedTime: () => post.value?.publishedAt,
  articleModifiedTime: () => post.value?.updatedAt,
  articleAuthor: () => post.value?.author.name,
  articleTag: () => post.value?.tags,
})

// JSON-LD Schema markup para Google
useSchemaOrg([
  defineArticle({
    headline: () => post.value?.title,
    description: () => post.value?.excerpt,
    image: () => post.value?.coverImage,
    datePublished: () => post.value?.publishedAt,
    dateModified: () => post.value?.updatedAt,
    author: {
      '@type': 'Person',
      name: () => post.value?.author.name,
    }
  })
])
</script>

useHead — Control completo del

<script setup lang="ts">
useHead({
  // Título con template
  titleTemplate: (title) => title ? `${title} | Mi Empresa` : 'Mi Empresa',
  title: 'Dashboard',

  // Meta tags
  meta: [
    { name: 'viewport', content: 'width=device-width, initial-scale=1' },
    { name: 'theme-color', content: '#4f46e5' },
  ],

  // Links
  link: [
    { rel: 'canonical', href: 'https://ejemplo.com/dashboard' },
    { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
    { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
  ],

  // Scripts
  script: [
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'WebSite',
        name: 'Mi Empresa',
        url: 'https://ejemplo.com'
      })
    }
  ],

  // HTML lang attribute
  htmlAttrs: { lang: 'es' },

  // Body attrs
  bodyAttrs: { class: 'antialiased' },
})
</script>

14. Nuxt Modules Esenciales

El ecosistema de módulos de Nuxt es enorme. Estos son los más importantes para un proyecto en 2025.

@nuxt/ui — Componentes de UI

pnpm add @nuxt/ui
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui']
})

Nuxt UI v4 es ahora completamente gratuito (unificó Nuxt UI y Nuxt UI Pro). Incluye 110+ componentes listos para producción con Tailwind CSS v4.

<template>
  <div>
    <UButton color="primary" size="lg" @click="doSomething">
      Click me
    </UButton>

    <UModal v-model="isOpen">
      <UCard>
        <template #header>
          <h3>Título del modal</h3>
        </template>
        <p>Contenido del modal</p>
      </UCard>
    </UModal>

    <UTable :data="users" :columns="columns" virtualize />

    <UForm :schema="schema" :state="formState" @submit="onSubmit">
      <UFormField label="Nombre" name="name">
        <UInput v-model="formState.name" />
      </UFormField>
      <UButton type="submit">Enviar</UButton>
    </UForm>
  </div>
</template>

@nuxt/content — CMS basado en archivos

pnpm add @nuxt/content
content/
├── blog/
│   ├── primer-post.md
│   └── segundo-post.md
└── docs/
    └── introduccion.md
<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const { data: post } = await useAsyncData(
  () => queryContent(`/blog/${route.params.slug}`).findOne()
)
</script>

<template>
  <article>
    <h1>{{ post.title }}</h1>
    <ContentRenderer :value="post" />
  </article>
</template>

@nuxt/image — Optimización de imágenes

pnpm add @nuxt/image
<template>
  <!-- Optimización automática: WebP, lazy, responsive -->
  <NuxtImg
    src="/hero.jpg"
    alt="Hero image"
    width="1200"
    height="630"
    format="webp"
    quality="85"
    loading="lazy"
    sizes="sm:100vw md:50vw lg:800px"
  />

  <!-- Con CDN externo -->
  <NuxtImg
    src="https://images.unsplash.com/photo-xxx"
    provider="unsplash"
    width="800"
    height="600"
  />
</template>

@nuxtjs/i18n — Internacionalización

pnpm add @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'es', name: 'Español', file: 'es.json' },
      { code: 'en', name: 'English', file: 'en.json' },
    ],
    defaultLocale: 'es',
    langDir: 'locales/',
    strategy: 'prefix_except_default',
  }
})
// locales/es.json
{
  "welcome": "Bienvenido, {name}",
  "nav": {
    "home": "Inicio",
    "about": "Sobre nosotros"
  }
}
<script setup lang="ts">
const { t, locale, locales, setLocale } = useI18n()
</script>

<template>
  <p>{{ t('welcome', { name: 'Juan' }) }}</p>
  <NuxtLinkLocale to="/about">{{ t('nav.about') }}</NuxtLinkLocale>
</template>

@pinia/nuxt — Estado global avanzado

pnpm add pinia @pinia/nuxt
// app/stores/useUserStore.ts
export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  const isLoading = ref(false)

  const isLoggedIn = computed(() => !!user.value)

  async function fetchUser() {
    isLoading.value = true
    try {
      user.value = await $fetch('/api/auth/me')
    } finally {
      isLoading.value = false
    }
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }

  return { user, isLoading, isLoggedIn, fetchUser, logout }
})

15. Nuxt UI v4: El Design System Oficial

Nuxt UI v4 (lanzado en septiembre 2025) es ahora completamente gratuito y es una de las mejores librerías de UI para Vue.

Configuración

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
  // Opcional: tema personalizado
  ui: {
    // Colores del design system
    primary: 'violet',
    gray: 'slate',
  }
})
/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";

Theming con app.config.ts

// app/app.config.ts
export default defineAppConfig({
  ui: {
    primary: 'violet',
    gray: 'slate',
    button: {
      // Customizar tokens del componente Button
      rounded: 'rounded-full',
    },
    card: {
      rounded: 'rounded-2xl',
      shadow: 'shadow-lg',
    }
  }
})

Componentes clave

<template>
  <div class="p-8 space-y-6">

    <!-- Buttons -->
    <div class="flex gap-3">
      <UButton color="primary">Primary</UButton>
      <UButton color="gray" variant="outline">Outline</UButton>
      <UButton color="red" variant="soft">Soft</UButton>
      <UButton loading>Loading</UButton>
    </div>

    <!-- Input con validación -->
    <UFormField label="Email" name="email" :error="errors.email">
      <UInput
        v-model="email"
        type="email"
        placeholder="tu@email.com"
        icon="i-heroicons-envelope"
      />
    </UFormField>

    <!-- Select -->
    <USelectMenu
      v-model="selected"
      :items="options"
      placeholder="Seleccionar..."
      searchable
    />

    <!-- Table con virtualización para datasets grandes -->
    <UTable
      :data="users"
      :columns="[
        { key: 'name', label: 'Nombre', sortable: true },
        { key: 'email', label: 'Email' },
        { key: 'role', label: 'Rol' },
      ]"
      virtualize
    />

    <!-- Notification -->
    <UButton @click="$notify({ title: '¡Éxito!', description: 'Post guardado', color: 'green' })">
      Mostrar notificación
    </UButton>

  </div>
</template>

16. Deployment y Estrategias de Rendering

Modos de rendering en Nuxt

┌─────────────────────────────────────────────────────────────────┐
│               ESTRATEGIAS DE RENDERING EN NUXT 4               │
│                                                                 │
│  SSR (Server Side Rendering)    — Default                       │
│  └── HTML generado por request en servidor                     │
│  └── Bueno para: Contenido dinámico, autenticado               │
│                                                                 │
│  SSG (Static Site Generation)   — nuxt generate                │
│  └── HTML pre-generado en build time                           │
│  └── Bueno para: Blogs, documentación, landing pages           │
│                                                                 │
│  ISR (Incremental Static Regen) — routeRules: { isr: N }       │
│  └── SSG con revalidación periódica                            │
│  └── Bueno para: Tiendas, noticias, contenido semi-dinámico    │
│                                                                 │
│  SWR (Stale While Revalidate)   — routeRules: { swr: N }       │
│  └── Sirve caché, regenera en background                       │
│  └── Bueno para: APIs, datos que cambian frecuentemente        │
│                                                                 │
│  Híbrido                        — routeRules por ruta           │
│  └── Diferentes estrategias para diferentes rutas              │
│  └── Bueno para: Apps complejas con requisitos mixtos          │
└─────────────────────────────────────────────────────────────────┘

Build para producción

# SSR (Node.js server)
pnpm build
# → .output/server/index.mjs

# SSG (Static)
pnpm generate
# → .output/public/

# Preview local
pnpm preview

Deployment en Vercel

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'vercel'
  }
})
// vercel.json (opcional — Nuxt lo detecta automáticamente)
{
  "framework": "nuxt"
}

Deployment en Cloudflare Pages

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    preset: 'cloudflare-pages'
  }
})
# Build para Cloudflare
pnpm build
# Sube .output/public/ y .output/server/ a Cloudflare Pages

Deployment con Docker (Node.js)

# Dockerfile
FROM node:20-alpine AS base

# Dependencias
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm install -g pnpm && pnpm build

# Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder /app/.output ./.output

EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# docker-compose.yml
version: '3.8'

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
      - NUXT_PUBLIC_API_BASE=https://api.ejemplo.com
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

17. Migrar de Nuxt 3 a Nuxt 4

La migración está diseñada para ser lo más sencilla posible. La mayoría de proyectos Nuxt 3 migran en minutos.

Método 1: Migración automática con Codemod

# Actualizar Nuxt
npx nuxt upgrade --dedupe

# Ejecutar codemods automáticos (recomendado)
pnpm dlx codemod@latest nuxt/4/migration-recipe

El codemod automáticamente:

  • Mueve archivos a la carpeta app/
  • Actualiza imports
  • Migra APIs deprecadas
  • Actualiza referencias de configuración

Método 2: Migración manual paso a paso

Paso 1: Actualizar

pnpm add nuxt@^4.0.0

Paso 2: Crear la carpeta app/ y mover archivos

mkdir app

# Mover directorios de app
mv assets app/
mv components app/
mv composables app/
mv layouts app/
mv middleware app/
mv pages app/
mv plugins app/
mv utils app/

# Mover archivos raíz de app
mv app.vue app/
mv app.config.ts app/
mv error.vue app/

Paso 3: Actualizar paths en nuxt.config.ts

// ❌ Nuxt 3 — paths desde la raíz
css: ['~/assets/css/main.css']

// ✅ Nuxt 4 — el mismo path funciona (~ sigue siendo la raíz del proyecto)
// No necesitas cambiar los paths de assets con ~

Paso 4: Revisar cambios de TypeScript

pnpm typecheck
# Pueden aparecer errores que antes estaban ocultos — son legítimos, corregirlos

Paso 5: Revisar valores null → undefined

// ❌ Nuxt 3 — data empieza como null
const { data } = useFetch('/api/users')
if (data.value === null) { /* cargando o error */ }

// ✅ Nuxt 4 — data empieza como undefined
const { data } = useFetch('/api/users')
if (data.value === undefined) { /* cargando o error */ }
// O mejor:
if (!data.value) { /* cargando o error */ }

Breaking changes resumidos

BREAKING CHANGES DE NUXT 4:
├── Carpeta app/ (estructura nueva, la vieja sigue funcionando)
├── data y error por defecto: undefined (antes null)
├── Estilos inline: solo componentes Vue (no CSS global)
├── Nuxt 2 compat eliminada de @nuxt/kit
├── Unhead v2 (elimina algunas APIs deprecadas de v1)
└── TypeScript multi-proyecto (puede revelar errores ocultos)

Habilitar gradualmente (para proyectos grandes)

// nuxt.config.ts — Puedes habilitar solo ciertos cambios
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4
  },

  // Revertir cambios específicos si necesitas tiempo
  experimental: {
    defaults: {
      useAsyncData: {
        value: 'null'  // Volver a null si tienes mucho código dependiente
      }
    }
  }
})

18. Buenas Prácticas y Patrones Avanzados

Estructura de componentes recomendada

<!-- El orden estándar dentro de un SFC -->
<script setup lang="ts">
// 1. Imports (solo si no son auto-importados)
import { SomeExternalLibrary } from 'external-lib'

// 2. Props & Emits
const props = defineProps<{
  userId: string
  showAvatar?: boolean
}>()

const emit = defineEmits<{
  updated: [user: User]
  deleted: [id: string]
}>()

// 3. Composables & Stores
const router = useRouter()
const { user, isLoggedIn } = useUser()

// 4. Reactive state local
const isEditing = ref(false)
const formData = reactive({ name: '', email: '' })

// 5. Computed
const fullName = computed(() => `${user.value?.firstName} ${user.value?.lastName}`)

// 6. Data fetching
const { data: profile } = await useFetch(`/api/users/${props.userId}`)

// 7. Functions
function startEditing() {
  isEditing.value = true
  formData.name = user.value?.name ?? ''
}

async function saveChanges() {
  await $fetch(`/api/users/${props.userId}`, {
    method: 'PUT',
    body: formData
  })
  emit('updated', formData as User)
  isEditing.value = false
}

// 8. Lifecycle hooks
onMounted(() => {
  // Solo en cliente
})

// 9. Watchers
watch(() => props.userId, () => {
  // Reaccionar a cambios del prop
})
</script>

<template>
  <!-- Template aquí -->
</template>

<style scoped>
/* Styles aquí (scoped = sin contaminar otros componentes) */
</style>

Error handling robusto

// app/composables/useApi.ts
export function useApi() {
  const toast = useToast() // De Nuxt UI

  async function request<T>(
    url: string,
    options?: Parameters<typeof $fetch>[1]
  ): Promise<T | null> {
    try {
      return await $fetch<T>(url, options)
    } catch (error: any) {
      // Errores del servidor
      if (error.statusCode === 401) {
        navigateTo('/login')
        return null
      }

      if (error.statusCode === 403) {
        toast.add({
          title: 'Sin permiso',
          description: 'No tienes acceso a este recurso',
          color: 'red'
        })
        return null
      }

      // Error genérico
      toast.add({
        title: 'Error',
        description: error.statusMessage || 'Algo salió mal',
        color: 'red'
      })

      return null
    }
  }

  return { request }
}

Patrón Repository para data access

// app/repositories/PostRepository.ts
export const usePostRepository = () => {
  const config = useRuntimeConfig()
  const baseUrl = config.public.apiBase

  return {
    async findAll(params?: { page?: number; category?: string }) {
      return $fetch(`${baseUrl}/posts`, { query: params })
    },

    async findBySlug(slug: string) {
      return $fetch(`${baseUrl}/posts/${slug}`)
    },

    async create(data: Omit<Post, 'id' | 'createdAt'>) {
      return $fetch(`${baseUrl}/posts`, { method: 'POST', body: data })
    },

    async update(id: string, data: Partial<Post>) {
      return $fetch(`${baseUrl}/posts/${id}`, { method: 'PUT', body: data })
    },

    async delete(id: string) {
      return $fetch(`${baseUrl}/posts/${id}`, { method: 'DELETE' })
    },
  }
}

Optimistic Updates

<script setup lang="ts">
const { data: todos, refresh } = await useFetch('/api/todos')

async function toggleTodo(todo: Todo) {
  // 1. Actualizar optimistamente (UI instantánea)
  todo.completed = !todo.completed

  try {
    // 2. Persistir en servidor
    await $fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: { completed: todo.completed }
    })
  } catch {
    // 3. Revertir si falla
    todo.completed = !todo.completed
    // Mostrar error al usuario
  }
}
</script>

19. Nuxt DevTools

Nuxt DevTools es una herramienta integrada que transforma la experiencia de desarrollo.

Activar DevTools

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: {
    enabled: true,      // Activar (default en desarrollo)
    timeline: {
      enabled: true,    // Timeline de composables
    }
  }
})

Accede en: http://localhost:3000/__nuxt_devtools__/

Funcionalidades principales

NUXT DEVTOOLS — PANELES DISPONIBLES:

📄 Pages       → Ver todas las rutas, navegar entre ellas
🧩 Components  → Árbol de componentes en la página actual
🔄 Imports     → Auto-imports activos y su origen
🗺️ Routes      → Todas las rutas definidas
🔌 Plugins     → Plugins cargados
📦 Modules     → Módulos activos con sus configuraciones
💾 Assets      → Archivos en assets/ y public/
📡 Fetch       → Requests realizadas (con payloads)
🍍 Pinia       → Estado de stores (si usas Pinia)
📝 Payload     → Data del servidor transmitida al cliente
🔵 TypeScript  → Errores de tipo en tiempo real
⚡ Performance → Métricas de rendimiento

Error overlay (Nuxt 4.2)

El nuevo error overlay en desarrollo es arrastrable y minimizable — puedes moverlo a cualquier esquina de la pantalla. Tu posición persiste entre recargas.


20. Qué viene: Nuxt 5

Nuxt 5 está en desarrollo activo y se espera pronto (Q1/Q2 2026).

NUXT 5 — LO QUE VIENE:

⚡ Nitro v3 + h3 v2
   └── Mejor rendimiento en servidor
   └── Tipos más fuertes en API routes
   └── fetch más estrictamente tipado

🔧 Vite Environment API
   └── Mejor experiencia de desarrollo
   └── HMR más rápido
   └── Soporte mejorado para Edge

🌊 SSR Streaming
   └── Enviar HTML mientras se genera
   └── TTFB más rápido para páginas lentas
   └── React Suspense-like para Vue

♿ Módulo de Accesibilidad oficial
   └── Auditorías integradas
   └── Warnings en desarrollo

🔒 Fetch tipado de extremo a extremo
   └── Si defines un server route con un tipo...
   └── ...useFetch lo conoce automáticamente
   └── Sin necesidad de duplicar tipos

📦 Multi-app support
   └── Múltiples apps Nuxt en un monorepo
   └── Compartir estado entre apps

Para seguir las novedades en tiempo real: nuxt.com/blog


Checklist: Proyecto Nuxt 4 Listo para Producción

### ✅ Configuración Base
- [ ] nuxt.config.ts con compatibilityDate definido
- [ ] runtimeConfig configurado (nunca secrets en public)
- [ ] .env con todas las variables documentadas en .env.example
- [ ] TypeScript strict: true activado
- [ ] ESLint con @nuxt/eslint configurado

### ✅ Rendimiento
- [ ] Imágenes usando <NuxtImg> con lazy loading
- [ ] routeRules configurado según necesidades de cada ruta
- [ ] experimental.asyncDataHandler activado (Nuxt 4.2+)
- [ ] CSS crítico no bloqueante

### ✅ SEO
- [ ] useSeoMeta en todas las páginas
- [ ] Canonical URLs configuradas
- [ ] Open Graph images (1200x630px)
- [ ] Sitemap generado (@nuxtjs/sitemap)
- [ ] robots.txt configurado

### ✅ Seguridad
- [ ] Headers de seguridad (nuxt-security module)
- [ ] Inputs del servidor validados (zod, valibot)
- [ ] Auth middleware en rutas protegidas
- [ ] Rate limiting en API routes

### ✅ DX (Developer Experience)
- [ ] DevTools activado en desarrollo
- [ ] Prettier + ESLint configurados
- [ ] Husky + lint-staged para commits limpios
- [ ] Tests con Vitest + @nuxt/test-utils
- [ ] CI/CD con GitHub Actions

### ✅ Deployment
- [ ] Variables de entorno configuradas en plataforma
- [ ] Health check endpoint (/api/health)
- [ ] Error monitoring (Sentry, etc.)
- [ ] Analytics configurado
- [ ] Preview deployments para PRs

Recursos Oficiales


Artículo basado en Nuxt 4.3.1 — noviembre 2025. Última revisión: febrero 2026.

Tags: nuxt4 vue fullstack javascript typescript ssr webdev