AgroSmart — Refactor Nuxt 4

Corrección de hydration mismatches · Data fetching seguro · Route rules · Lazy loading

Nuxt 4 CULTIVA IA
1

Diagnóstico — 3 bugs + 2 optimizaciones detectadas

💥 Bug · Hidratación
Date.now() en template SSR
/dashboard — La fecha se renderiza en servidor y luego "salta" al valor cliente. Causa: new Date() directo en setup(), fuera de onMounted.
💥 Bug · Data Fetching
$fetch top-level en setup()
/fincas/[slug] — Doble fetch (SSR + cliente), payload enorme con campos innecesarios. Causa: $fetch en lugar de useFetch con pick.
⚙️ Bug · Route Rules
Admin cargando con SSR
/admin/** — Panel privado renderizado en servidor innecesariamente. Sin beneficio SEO y con riesgo de exponer rutas protegidas en HTML inicial.
Optimización · Bundle
MapaInteractivo en bundle principal
El mapa Leaflet (~450 KB) se incluye en el chunk principal aunque solo se muestra al hacer clic en una pestaña.
Optimización · Fetching
Comentarios bloqueando navegación
Los comentarios de incidencias se fetch junto con datos principales. No son críticos para el primer paint; deberían ser lazy.
2

nuxt.config.ts — Route Rules por estrategia

nuxt.config.ts CORREGIDO
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 } },
  },
})
Estrategia por sección RESUMEN
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
3

Corrección de Hydration Mismatch — dashboard/index.vue

❌ ANTES — Mismatch garantizado
// 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
)
✅ DESPUÉS — Primer render determinista
// 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')  // ✓
})
pages/dashboard/index.vue CORREGIDO
<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>
4

Data Fetching SSR-safe — composables/useFinca.ts

composables/useFinca.ts NUEVO
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,
  }
}
pages/fincas/[slug].vue CORREGIDO
<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>
5

Lazy Loading y Lazy Hydration — MapaInteractivo

❌ ANTES — Cargado siempre en bundle principal
// 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
✅ DESPUÉS — Chunk separado + lazy hydration
// ✓ 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)
components/MapaInteractivo.vue — estrategia de hidratación custom OPTIMIZADO
<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>
6

Checklist de validación — AgroSmart post-refactor

Resultado final
  • Primer render SSR y cliente producen el mismo markup (sin Date.now() ni Math.random() en template)
  • Datos de página usan useFetch / useAsyncData con clave estable, no $fetch top-level
  • Datos no críticos (incidencias) son lazy con UI de carga explícita (status === 'pending')
  • Route rules por sección: marketing prerender, catálogo SWR, admin CSR-only
  • MapaInteractivo (Leaflet ~450 KB) fuera del bundle principal, hidratación lazy on-visible
  • Payload trimado con pick: finca de ~8 KB → ~1.2 KB por response
  • useRoute() de Nuxt, no de vue-router directamente
  • $fetch solo para mutaciones disparadas por el usuario (PATCH/POST)
7

Impacto estimado del refactor

📦 Bundle principal
-450 KB de JS inicial
Leaflet movido a chunk lazy. Se descarga solo si el usuario abre la pestaña Mapa.
🌐 Payload API
-85% en datos de finca
De ~8 KB a ~1.2 KB por request usando pick en useFetch. Sin doble fetch SSR+cliente.
Hydration
0 warnings en consola
Fecha y localStorage movidos a onMounted. SSR y cliente producen HTML idéntico.
🔒 Seguridad / SEO
Admin sin HTML público
Panel privado con ssr: false. Rutas de admin no aparecen en caché de CDN ni crawlers.