Corrección de hydration mismatches · Data fetching seguro · Route rules · Lazy loading
/dashboard — La fecha se renderiza en servidor y luego "salta" al valor cliente. Causa: new Date() directo en setup(), fuera de onMounted./fincas/[slug] — Doble fetch (SSR + cliente), payload enorme con campos innecesarios. Causa: $fetch en lugar de useFetch con pick./admin/** — Panel privado renderizado en servidor innecesariamente. Sin beneficio SEO y con riesgo de exponer rutas protegidas en HTML inicial.export default defineNuxtConfig({ routeRules: { // Marketing — HTML estático en build, máximo caché CDN '/': { prerender: true }, '/precios': { prerender: true }, '/sobre-nosotros': { prerender: true }, // Catálogo de fincas — SWR 1h: sirve caché, revalida en bg '/fincas': { swr: 3600 }, '/fincas/**': { swr: 1800 }, // Blog / documentación — ISR: regenera on-demand '/blog/**': { isr: true }, // Dashboard app — SSR normal (datos en tiempo real) '/dashboard/**': { ssr: true }, // Admin — SIN SSR: panel privado, sin valor SEO '/admin/**': { ssr: false }, // ← FIX: era ssr: true // API — caché Nitro a nivel edge '/api/**': { cache: { maxAge: 60 * 60 } }, }, })
| Ruta | Regla | Estrategia | Por qué |
|---|---|---|---|
| / | prerender | 🟢 Estático | Marketing inmutable, máximo SEO y velocidad |
| /fincas/** | swr: 1800 | 🔵 SWR 30min | Datos semi-frecuentes, respuesta inmediata + revalidación |
| /blog/** | isr: true | 🟡 ISR | Contenido editorial actualizable sin rebuild global |
| /dashboard/** | ssr: true | 🟠 SSR | Datos en tiempo real (sensores, alertas activas) |
| /admin/** | ssr: false | 🔴 CSR only | Panel privado, sin necesidad de SEO ni HTML público |
// setup() ejecuta en SSR Y en cliente const ahora = ref(new Date()) // ← PROBLEMA // Server: "2026-06-18T09:00:00.012Z" // Client: "2026-06-18T09:00:00.891Z" // → Vue emite warning de hidratación const usuario = ref( localStorage.getItem('user') // ← localStorage no existe en SSR )
// Nulo hasta que el cliente monta const ahora = ref<Date | null>(null) onMounted(() => { ahora.value = new Date() // Solo en cliente ✓ }) const usuario = ref<string | null>(null) onMounted(() => { usuario.value = localStorage.getItem('user') // ✓ })
<script setup lang="ts"> import { ref, onMounted, computed } from 'vue' import type { DashboardStats } from '~/types' // ✓ useFetch: SSR-safe, payload forwarded, sin doble fetch const { data: stats, status } = await useFetch<DashboardStats>( '/api/dashboard/stats', { pick: ['totalFincas', 'alertasActivas', 'rendimientoMedio'] } ) // ✓ Fecha solo en cliente — nunca en SSR const ahora = ref<Date | null>(null) const horaFormateada = computed(() => ahora.value ? ahora.value.toLocaleTimeString('es-ES') : '--:--:--' // placeholder SSR neutro ) onMounted(() => { ahora.value = new Date() setInterval(() => { ahora.value = new Date() }, 1000) }) </script> <template> <div class="dashboard"> <!-- ClientOnly: hora nunca se renderiza en SSR → no hay mismatch --> <ClientOnly> <p class="timestamp">{{ horaFormateada }}</p> <template #fallback><p class="timestamp skeleton">--:--:--</p></template> </ClientOnly> <DashboardStats v-if="stats" :data="stats" /> </div> </template>
import type { Finca, Incidencia } from '~/types' export function useFinca(slug: string) { // ✓ useFetch: SSR → payload → cliente sin doble petición // pick: solo los campos que necesita el template → payload mínimo const { data: finca, status: fincaStatus, error: fincaError, refresh: refreshFinca } = await useFetch<Finca>( `/api/fincas/${slug}`, { key: `finca:${slug}`, pick: [ 'id', 'nombre', 'ubicacion', 'hectareas', 'cultivo', 'estado', 'alertas', 'ultimaActualizacion' ], // ← antes: objeto completo ~8 KB → ahora: ~1.2 KB } ) // ✓ useLazyFetch: no bloquea navegación, muestra spinner independiente // server: false → no necesario para SEO ni primer paint const { data: incidencias, status: incidenciasStatus } = useLazyFetch<Incidencia[]>( `/api/fincas/${slug}/incidencias`, { key: `finca:${slug}:incidencias`, server: false, pick: ['id', 'tipo', 'descripcion', 'fecha', 'resuelta'], } ) return { finca, fincaStatus, fincaError, refreshFinca, incidencias, incidenciasStatus, } }
<script setup lang="ts"> const route = useRoute() // ✓ useRoute() de Nuxt, no vue-router const { finca, fincaStatus, incidencias, incidenciasStatus } = await useFinca(route.params.slug as string) // ✓ $fetch solo para acciones del usuario (mutación) async function marcarResuelta(id: string) { await $fetch(`/api/incidencias/${id}/resolver`, { method: 'PATCH' }) } </script> <template> <main> <FincaHeader v-if="finca" :finca="finca" /> <!-- Lazy: mapa solo cuando usuario lo solicita --> <LazyMapaInteractivo v-if="mostrarMapa" hydrate-on-visible :coordenadas="finca?.coordenadas" /> <!-- Incidencias: lazy, con estado de carga propio --> <section class="incidencias"> <LoadingSpinner v-if="incidenciasStatus === 'pending'" /> <IncidenciasList v-else :items="incidencias" @resolver="marcarResuelta" /> </section> </main> </template>
// En el template: <MapaInteractivo :coordenadas="finca.coordenadas" /> // Leaflet (~450 KB) incluido en chunk // principal aunque el tab "Mapa" no // esté activo en el primer paint
// ✓ Prefijo Lazy → import() dinámico <LazyMapaInteractivo v-if="tabActivo === 'mapa'" hydrate-on-visible :coordenadas="finca.coordenadas" /> // Leaflet NO entra en bundle principal // Se hidratan solo cuando entra en // el viewport (IntersectionObserver)
<script setup lang="ts"> // defineLazyHydrationComponent: estrategia personalizada const MapaComplejo = defineLazyHydrationComponent( () => import('~/components/MapaLeaflet.vue'), { hydrateOnVisible: { rootMargin: '200px' }, // Alternativa para zonas interactivas pesadas: // hydrateOnIdle: true } ) </script> <template> <div class="mapa-wrapper"> <Suspense> <MapaComplejo v-bind="$attrs" /> <template #fallback> <div class="mapa-placeholder">Cargando mapa…</div> </template> </Suspense> </div> </template>
Date.now() ni Math.random() en template)useFetch / useAsyncData con clave estable, no $fetch top-levelstatus === 'pending')pick: finca de ~8 KB → ~1.2 KB por responseuseRoute() de Nuxt, no de vue-router directamente$fetch solo para mutaciones disparadas por el usuario (PATCH/POST)pick en useFetch. Sin doble fetch SSR+cliente.localStorage movidos a onMounted. SSR y cliente producen HTML idéntico.ssr: false. Rutas de admin no aparecen en caché de CDN ni crawlers.