1. El problema: SPA pura vs. Servidor SSR vs. Generación Estática (SSG)
Cuando compilas una aplicación tradicional de una sola página (SPA) con Vue o React, el archivo index.html publicado suele ser poco más que un contenedor vacío:
<div id="app"></div>
<script type="module" src="/assets/main.js"></script>Esto plantea dos inconvenientes graves:
- Rastreo deficiente y retrasos de indexación: Aunque Googlebot es capaz de ejecutar JavaScript en una segunda fase de renderizado diferido (Web Rendering Service), otros rastreadores (redes sociales, agregadores, bots ligeros) no ejecutan scripts o interpretan la página como contenido vacío o de bajo valor.
- Latencia en el primer renderizado con contenido (FCP): Los visitantes en dispositivos móviles o conexiones inestables ven una pantalla en blanco hasta que el navegador descarga, parsea y ejecuta los paquetes JavaScript.
La solución clásica es montar un servidor Node.js en tiempo de ejecución (SSR dinámico). Sin embargo, para un sitio con contenido estable como PonlOnline, un servidor SSR añade complejidad operativa, monitorización de procesos 24/7, vulnerabilidades potenciales y costos de infraestructura continuos.
La alternativa que implementamos es generación de HTML en tiempo de compilación (SSG / Prerendering): ejecutamos Vue una sola vez durante el comando npm run build, generamos archivos HTML completos con enlaces y metadatos propios, y los desplegamos en la red de distribución de contenidos (CDN) global de Firebase Hosting.
2. El pipeline de compilación dual en package.json
Para generar el HTML estático sin ensuciar el código del cliente, dividimos la compilación en fases bien delimitadas dentro del script build de package.json:
"build": "vue-tsc -b && vite build && vite build --ssr src/entry-server.ts --outDir .prerender && node scripts/prerender.mjs && node scripts/verify-seo.mjs"Analicemos el propósito exacto de cada eslabón de esta cadena:
vue-tsc -b: Comprueba los tipos de TypeScript en toda la aplicación (componentes.vueincluidos) en modo estricto antes de emitir ningún archivo. Si hay un error de tipado, el build se detiene de inmediato.vite build: Compila el paquete cliente estándar en el directoriodist/: minifica el código JavaScript, procesa los estilos con Tailwind CSS y emite el archivo plantilladist/index.htmlque contiene el marcador de posición<!--ssr-outlet-->.vite build --ssr src/entry-server.ts --outDir .prerender: Compila un paquete independiente diseñado para ejecutarse en Node.js, emitiendo una función exportadarender(url)en.prerender/entry-server.js. Este directorio temporal está excluido de Git y del despliegue en producción.node scripts/prerender.mjs: Itera sobre las rutas canónicas del sitio, ejecutarender(url)en memoria y escribe los archivos HTML finales.node scripts/verify-seo.mjs: Comprueba automáticamente la validez de los encabezados, enlaces canónicos y directivas de rastreo de cada archivo generado.
3. El punto de entrada del servidor (src/entry-server.ts)
El archivo src/entry-server.ts es deliberadamente austero. Utiliza createMemoryHistory() en lugar de createWebHistory() porque en el entorno de Node.js no existe el objeto window ni la API de historial del navegador:
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'
import { createSiteRouter } from './router'
export async function render(url: string) {
const router = createSiteRouter()
const app = createSSRApp(App).use(createPinia()).use(router)
await router.push(url)
await router.isReady()
return {
html: await renderToString(app),
title: String(router.currentRoute.value.meta.title),
description: String(router.currentRoute.value.meta.description),
}
} Al invocar router.push(url) y esperar a router.isReady(), Vue resuelve de forma asíncrona cualquier componente cargado dinámicamente mediante () => import('./pages/...'). Una vez completado, renderToString(app) serializa el árbol de componentes en una cadena HTML pura.
4. Inyección de HTML y metadatos (scripts/prerender.mjs)
El script scripts/prerender.mjs toma la plantilla base dist/index.html y sustituye los marcadores por la salida real del componente renderizado:
// Inyección del HTML del componente y marcado del contenedor hidratable
let result = template
.replace('<!--ssr-outlet-->', html)
.replace('<div id="app">', '<div id="app" data-prerendered>');
// Metadatos individuales por ruta
result = result.replace(/<title>[\s\S]*?<\/title>/, `<title>${escape(title)}</title>`);
result = result.replace(/<meta name="description" content="[^"]*" \/>/, `<meta name="description" content="${escape(description)}" />`);
// Inyección del grafo de Schema.org antes del cierre del head
result = result.replace('</head>', `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@graph': graph
})}</script>\n</head>`); Observa el atributo data-prerendered: es el identificador que comunica el proceso de compilación con el cliente en tiempo de ejecución.
5. La estrategia de hidratación en el cliente (src/main.ts)
Cuando un visitante llega al sitio web, el navegador descarga el HTML prerenderizado y dibuja el contenido de inmediato, sin esperar a que el archivo JavaScript termine de descargarse.
Una vez que el script de cliente (src/main.ts) arranca, debe conectar los escuchadores de eventos (botones, formularios, navegación) sin recrear los nodos del DOM desde cero:
import { createApp, createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
const bootstrap = document.getElementById('app')?.hasAttribute('data-prerendered')
? createSSRApp
: createApp
const app = bootstrap(App).use(createPinia()).use(router)
void router.isReady().then(() => app.mount('#app')) Si el elemento #app tiene el atributo data-prerendered, se invoca createSSRApp. Vue asume que el árbol DOM ya existe y únicamente "hidrata" los controladores reactivos. Si el atributo no existe (por ejemplo en entornos locales de prueba), recurre a createApp estándar.
6. Evitar el «Soft 404»: la configuración de Firebase Hosting
Uno de los errores más graves de SEO en proyectos con frameworks SPA es la regla clásica de comodín en el servidor:
// ¡Evita esto en sitios de contenido indexable!
"rewrites": [
{ "source": "**", "destination": "/index.html" }
] ¿Por qué es dañino? Si un bot o visitante escribe ponlonline.com/ruta-inexistente, el servidor entrega index.html con un código de estado HTTP 200 OK. Luego, Vue Router carga la vista 404 en el cliente.
Para los motores de búsqueda, esto constituye un Soft 404: la página declara que existe y funciona bien (código 200), pero su contenido dice "Página no encontrada". Si un sitio acumula URLs con Soft 404, los buscadores reducen la frecuencia de rastreo y la confianza global del dominio.
La solución estricta de PonlOnline en firebase.json:
Mantenemos "rewrites": [] completamente vacío. Firebase Hosting busca un archivo físico exacto (por ejemplo dist/recursos/index.html). Si no lo encuentra, sirve automáticamente el archivo dist/404.html devolviendo un encabezado de estado HTTP 404 real.
Además, durante el prerenderizado de la ruta /seo-not-found-test/ que genera dist/404.html:
- Reemplazamos
content="index, follow"porcontent="noindex, follow"para impedir que los buscadores indexen páginas de error. - Eliminamos los scripts de publicidad de Google AdSense para cumplir estrictamente con las políticas de inventario en páginas sin contenido útil.
7. Verificación automatizada con scripts/verify-seo.mjs
Ningún despliegue debería depender de comprobaciones manuales. Creamos scripts/verify-seo.mjs como parte obligatoria del comando npm run build. Este script ejecuta aserciones estrictas con el módulo nativo node:assert/strict:
for (const [file, url] of routes) {
const html = fs.readFileSync(file, 'utf8');
assert.match(html, /<h1[ >]/, `Falta etiqueta h1 en ${file}`);
assert.ok(html.includes(`rel="canonical" href="${url}"`), `Canónica incorrecta en ${file}`);
assert.ok(!/name="robots" content="[^"]*noindex/.test(html), `Bloqueo noindex accidental en ${file}`);
assert.ok(html.replace(/<[^>]*>/g, '').length > 500, `Contenido textual insuficiente en ${file}`);
} Si alguna página olvida un encabezado <h1>, desconfigura su URL canónica o emite menos de 500 caracteres de texto prerenderizado, la compilación falla antes de que los archivos lleguen a producción.