Prerenderizar React en un sitio Laravel en lugar de ejecutar un servidor SSR
Nuestras páginas de marketing son React, servidas por Laravel, y hasta este verano puntuaban alrededor de 85 en el perfil móvil de Lighthouse mientras estaban en 100 en accesibilidad, buenas prácticas y SEO. Todo lo barato ya estaba hecho. La imagen del hero era un PNG de 1,5 MB que se convirtió en unos 40 KB de AVIF detrás de un <picture> responsive. Cada ruta enviaba solo su propio JavaScript más un chunk compartido. El desplazamiento de diseño era cero y el tiempo de bloqueo, de 0 a 20 ms.
El first contentful paint y el largest contentful paint estaban los dos atascados en torno a 3,3 a 3,5 segundos, y nada de esa lista iba a moverlos.
Nada se pinta hasta que el JavaScript ha corrido
Todas las rutas servían el mismo armazón:
<body><div id="app"></div></body>
No hay nada visible hasta que el navegador descarga el JavaScript, lo interpreta, ejecuta React y renderiza el DOM. La ruta crítica la domina el chunk de vendor compartido —unos 63 KB comprimidos con gzip en nuestra build, sobre todo react-dom—, que se carga en todas las páginas. Con la limitación móvil de Lighthouse, 4G lenta con una ralentización de CPU de 4×, eso sumaba unos 3,3 segundos antes del primer píxel de contenido.
Por eso las palancas obvias no hacen nada:
| Lo que prueba | Lo que le hace al FCP |
|---|---|
| Imágenes más pequeñas | Ayuda un poco al LCP, una vez. Ya hecho. |
| Más code splitting | Nada. El chunk de vendor sigue cargándose primero. |
| Menos iconos, menos código de la aplicación | Marginal. react-dom domina. |
| Renderizar el HTML antes de que corra el JS | Lo único que lo mueve. |
El code splitting es el que despista a la gente. Es el consejo estándar, es realmente útil para la segunda y la tercera página que carga un visitante, y aquí no puede ayudar en absoluto: el chunk que bloquea el pintado es el que necesitan todas las rutas.
La pregunta que decide qué arreglo necesita
¿Algo del contenido depende de quién pregunta?
En nuestras páginas de marketing, no. Los textos, las tarjetas y los datos de servicios son constantes en los componentes React. No hay datos por petición. Todo lo realmente dinámico es una mejora del lado del cliente que corre después de la carga de todos modos: el conmutador de tema lo gestiona antes del pintado un script inline en el head, la aparición al hacer scroll y el menú móvil se enganchan en useEffect, y el formulario de contacto envía a un endpoint JSON de Laravel.
Cuando la respuesta es no, no necesita un servidor renderizando páginas por petición. Necesita que el HTML exista antes de que el navegador lo pida, y un paso de build puede hacerlo.
Miramos las alternativas como es debido antes de decidir, porque «añadir SSR» es la respuesta refleja y es cara:
- Inertia con SSR de Laravel. Encaja bien si la hoja de ruta añade páginas autenticadas o dependientes de datos. También significa adoptar el modelo de enrutado y de props de página de Inertia en todo el sitio y ejecutar un proceso Node de larga duración en producción, que monitorizar y que reiniciar a las tres de la mañana. Para cuatro páginas estáticas, son muchas piezas móviles para no ganar nada sobre un paso de build.
- Un servicio SSR en Node a medida. El mismo coste operativo, menos soporte del framework.
- Mover las páginas a Blade con islas de React. Realmente el resultado más rápido posible. También significa reimplementar el marcado del sistema de diseño en Blade y mantener dos versiones de él, que es como los diseños se separan.
- Cambiar React por Preact mediante
preact/compat. Barato, y sí reduce el chunk de vendor. La página sigue esperando al JavaScript para pintar, así que mejora la cifra sin arreglar la causa.
El paso de build
Tres añadidos y un cambio. Primero, una entrada SSR que mapea un nombre a un componente de página:
export function render(name) {
const Page = pages[name];
if (!Page) {
throw new Error(`Unknown prerender page: ${name}`);
}
return renderToString(createElement(Page));
}
export const pageNames = Object.keys(pages);
Después vite build --ssr lo compila en un bundle que Node puede importar, y un script de postbuild recorre las rutas y escribe un archivo HTML por cada una:
for (const name of pageNames) {
const html = render(name);
writeFileSync(resolve(outDir, `${name}.html`), html);
}
El comando de build pasa a ser vite build && vite build --ssr && node scripts/prerender.mjs. Blade inyecta el archivo correspondiente en el armazón, y las cuatro entradas del cliente cambian de createRoot(...).render() a hydrateRoot(...).
Nada nuevo corre en producción. La salida siguen siendo archivos estáticos que llegan con un git pull.
Dos cosas que le morderán
Cualquier cosa que lea window en el momento de renderizar. No hay window durante la build, así que un componente que lea window.location.pathname para decidir qué elemento de navegación está activo lanzará un error o, peor, renderizará algo con lo que el navegador después no está de acuerdo. El nuestro lee exactamente eso, y necesita una guarda typeof window !== 'undefined'. Cualquier cosa dentro de useEffect es segura, porque los efectos no corren durante la pasada del servidor.
Su entorno de desarrollo. Esta es la parte que habríamos hecho mal sin pensarlo. Vite sirve el CSS a través de JavaScript en desarrollo, así que no hay ninguna hoja de estilos que bloquee el renderizado, lo que significa que inyectar el marcado prerenderizado durante el desarrollo le da un destello de contenido completamente sin estilos en cada carga de página. La inyección está condicionada a que public/hot no exista, así que solo ocurre en la build de producción:
if (! file_exists(public_path('hot'))) {
$prerenderName = pathinfo($entry ?? 'resources/js/home.jsx', PATHINFO_FILENAME);
$prerenderFile = resource_path("prerendered/{$prerenderName}.html");
$prerenderedApp = is_file($prerenderFile) ? file_get_contents($prerenderFile) : '';
}
Un hábito relacionado: no haga preload de la imagen del hero después. Una vez que la página se prerenderiza, el elemento LCP es el texto del <h1>, y un preload de imagen con prioridad alta compite con el CSS que tiene que llegar antes de que ese texto pueda pintarse.
Lo que cambió
En nuestras propias ejecuciones de Lighthouse contra nuestro propio sitio, en el perfil móvil: Performance pasó de alrededor de 85 a 93–96 en las cuatro páginas, con FCP y LCP bajando de unos 3,3 segundos a 2,3–2,6. Accesibilidad, buenas prácticas y SEO se mantuvieron en 100. El trabajo llevó unos días, pruebas incluidas.
Dos salvedades, porque desde entonces hemos pasado el tiempo suficiente mirando estas cifras como para desconfiar de una sola ejecución. El Speed Index en móvil por sí solo oscila entre 98 y 100 en este sitio sin ningún cambio de código, así que una ejecución no demuestra nada en ninguna dirección: haga tres antes de creerse una regresión. Y una página que se ha prerenderizado sigue descargando y ejecutando el mismo bundle de React un momento después. Prerenderizar arregla cuándo aparece el contenido, no cuánto JavaScript acaba ejecutando el navegador.
Mantuvimos React en esas páginas de forma deliberada tras medirlo, que es una decisión distinta de no haberlo cuestionado nunca. El blog en el que lee esto es Blade puro y no envía ningún JavaScript de framework, porque un artículo no tiene nada que hidratar.
Por si sirve, el mismo sitio tampoco carga ningún JavaScript de terceros: la content security policy que lo impone es una pieza relacionada del mismo esfuerzo.
Si tiene una aplicación Laravel en la que el primer pintado es lento y los consejos habituales han dejado de ayudar, cuéntenos qué está ejecutando y le diremos lo que pensamos. Hacemos este tipo de trabajo en aplicaciones web con regularidad.
Preguntas frecuentes
¿Cuál es la diferencia entre prerenderizar y el renderizado en el servidor?
El renderizado en el servidor construye el HTML en cada petición, en un servidor que tiene que estar en marcha. Prerenderizar lo construye una vez en el deploy y sirve el mismo archivo a todo el mundo. Producen el mismo primer pintado. La diferencia solo importa cuando el contenido de una página depende de quién pregunta, en cuyo caso prerenderizar no puede hacer el trabajo.
¿Necesito Inertia para renderizar React en el servidor en una aplicación Laravel?
No. Inertia con su modo SSR encaja bien si sus páginas dependen de datos y son por usuario, pero implica adoptar el modelo de enrutado y de props de página de Inertia y ejecutar un proceso Node en producción. Si su contenido es estático, un renderizado en la build de los mismos componentes no necesita ninguna de las dos cosas.
¿Prerenderizar ayuda al SEO?
Google renderiza JavaScript, así que una página renderizada en el cliente puede indexarse igualmente. Los rastreadores de redes sociales en su mayoría no: LinkedIn, X y Slack leen el HTML inicial y nada más. Prerenderizar también mejora las métricas de carga que alimentan los Core Web Vitals, que es la parte que afecta al posicionamiento directamente.