Renderizado del Lado del Servidor (SSR) en Svelte 5 con Runes
Qué cubre esta habilidad
"SSR" significa que cada petición se renderiza a HTML en un servidor (Node, Bun, serverless o edge) antes de que el navegador lo reciba; los mismos componentes luego se hidratan convirtiéndose en una aplicación interactiva. A diferencia de una compilación solo estática, existe un servidor real en el momento de la petición, por lo que +page.server.js, +layout.server.js, +server.js, acciones de formulario y hooks.server.js están permitidos — y son donde deben estar los datos y secretos.
La parte de las runes es lo que hace complicado el SSR: cada componente se ejecuta dos veces, una en el servidor y otra en el navegador. El código que funciona bien en una aplicación solo-cliente aquí se rompe:
$effectnunca se ejecuta en el servidor, por lo que cualquier estado que inicialice falta en el HTML del servidor.- El
$statea nivel de módulo en archivos.svelte.tsse crea una vez por proceso del servidor y se comparte entre todas las peticiones de usuarios. - Los valores calculados a partir de
Date.now(),Math.random()olocalStoragedurante el renderizado difieren entre servidor y cliente, causando errores de hidratación.
Esos tres fallos explican la mayoría de los bugs de SSR en código con runes, y cada uno tiene un patrón correcto y pequeño más abajo.
Paso 1 — Elige la forma del proyecto
| Necesidad | Usa |
|---|---|
| Aplicación completa: rutas, carga de datos en servidor, formularios, autenticación, streaming | SvelteKit con adapter-auto (detección automática de Vercel/Netlify/Cloudflare), adapter-node (auto-alojado) o un adaptador de plataforma. Esta es la opción por defecto; úsala a menos que haya una razón para no hacerlo. |
| Un árbol de componentes Svelte dentro de un backend existente (Express/Hono/Fastify/Bun) | Vite + Svelte 5 plano: render() de svelte/server en el servidor, hydrate() en el navegador. Tú controlas enrutamiento, cabeceras y manejo de peticiones. |
| Contenido mayormente estático con algunas rutas dinámicas | SvelteKit con export const prerender = true por ruta (o 'auto' para prerenderizar páginas populares y renderizar en servidor la larga cola). Las rutas dinámicas aún siguen todas las reglas aquí. |
No establezcas export const ssr = false para "arreglar" un bug de SSR — eso convierte la ruta en un shell renderizado en cliente (una SPA), perdiendo SEO y la primera pintura. Es legítimo solo para páginas que genuinamente no pueden renderizarse sin globales del navegador. csr = false es el interruptor opuesto: HTML de servidor sin hidratación, para páginas que no necesitan interactividad.
Los pasos de configuración para ambos caminos, más los adaptadores y opciones de página, están en references/ssr-build.md. Léelo antes de generar svelte.config.js / vite.config.js, porque los nombres de las opciones y el comportamiento de los adaptadores difieren entre versiones y es fácil equivocarse sutilmente.
Paso 2 — Conoce qué runes se ejecutan en el servidor
Esta tabla es el núcleo de la habilidad:
| Rune | Durante SSR | Notas |
|---|---|---|
$state(v) | Se ejecuta | El valor inicial se renderiza. Mutarlo durante el renderizado no vuelve a renderizar el HTML ya emitido — SSR es un solo pase. |
$state.raw(v) | Se ejecuta | Sin proxy. Bueno para cargas de datos grandes que reemplazas completas en lugar de mutar. |
$derived(expr) | Se ejecuta | Se evalúa como parte del renderizado, por lo que su salida termina en el HTML. Mantenlo puro y libre de APIs solo del navegador. |
$effect / $effect.pre | Nunca | Solo navegador. El estado que establece está ausente del HTML del SSR (aparece después de la hidratación). Esto no es un error de hidratación, pero es HTML vacío y cambio de diseño visible. |
$effect.root(fn) | Nunca | Igual. No construyas estado visible en el servidor dentro de él. |
$props() / $bindable() | Se ejecuta | Los datos cargados llegan como props; los valores iniciales se renderizan. |
$props.id() | Se ejecuta | ID único seguro para SSR, consistente entre servidor y cliente (Svelte ≥ 5.20). Úsalo para enlaces for/aria-labelledby. |
$inspect(v) | Solo desarrollo | Sin salida en SSR. |
$state.snapshot(v) | Se ejecuta | Úsalo antes de pasar el estado a una API no-Svelte o librería del servidor. |
$effect.tracking() | false | El compilador del servidor lo reemplaza con el literal false; no hay contexto de seguimiento durante SSR. |
El bug clásico de SSR que esta tabla previene:
<!-- ❌ vacío en el HTML del servidor, luego aparece después de la hidratación -->
<script>
let user = $state();
$effect(() => { user = getUser(); });
</script>
<div>{user?.name}</div>
La solución es casi siempre la misma: calcula los valores visibles en SSR con $derived, u obténgelos en load y pásalos como props. Mantén $effect para cosas que solo existen en el navegador (canvas, observadores, analítica, sincronización de almacenamiento).
Lee references/runes-ssr.md para el comportamiento completo rune por rune en servidor, patrones de valores seguros para hidratación y el estado experimental del SSR asíncrono.
Paso 3 — Mantén el estado por petición, nunca global de módulo
// ❌ un objeto compartido para todo el proceso del servidor — el usuario A puede ver los datos del usuario B
// src/lib/user.svelte.ts
export const user = $state({ name: '' });
La solución canónica es devolver datos por petición desde load y pasarlos como props; cuando muchos componentes anidados lo necesitan, ponlo en contexto con un getter:
<!-- +layout.svelte -->
<script>
import { setContext } from 'svelte';
let { data } = $props();
setContext('user', () => data.user);
</script>
<!-- cualquier hijo -->
<script>
import { getContext } from 'svelte';
const user = getContext('user');
</script>
<p>Hola {user().name}</p>
El contexto está vinculado al árbol de componentes, y cada petición SSR tiene su propio árbol, por lo que no puede filtrarse entre usuarios. Pasa una función (u objeto con getters) a setContext, no el valor en sí, o se pierde la reactividad. Los datos por petición que muchas funciones del servidor necesitan pueden vivir en event.locals, poblados en hooks.server.ts.
El estado a nivel de módulo sigue siendo válido para estado de aplicación solo-cliente que ningún dato de usuario toca (tema, características), siempre que nada renderizado en servidor muestre valores personalizados desde él.
Paso 4 — Trata la hidratación como un contrato
La hidratación reproduce el HTML del servidor; el primer renderizado del cliente debe coincidir. Cualquier valor que difiera causa un hydration_mismatch, que deliberadamente no se puede suprimir — arregla el código, no la advertencia.
- Renderiza de manera determinista: nada de
Date.now(), nada de formatonew Date(), nada deMath.random(), nada decrypto.randomUUID(), nada de lecturas delocalStorage/window/navigatoren la ruta de renderizado. - Genera IDs con
$props.id(), no con aleatoriedad o contadores de módulo. - Formatea las fechas en el servidor dentro de
loady pasa cadenas, o pasa siempre unatimeZoneexplícita — las zonas horarias del servidor y del cliente difieren. - Valores solo de cliente (almacenamiento, media queries, geolocalización): empieza con un valor predeterminado seguro para servidor y completa dentro de
$effect/onMount, o protege el marcado con un flagmountedcon un fallback estable de servidor.
<script>
import { browser } from '$app/environment';
let theme = $state('light');
$effect(() => {
if (!browser) return;
theme = localStorage.getItem('theme') ?? 'light'; // después del montaje, para que la hidratación coincida
});
</script>
En SSR plano de Svelte (sin SvelteKit), $app/environment no está disponible — usa typeof window !== 'undefined' dentro de un efecto, nunca en la ruta de renderizado.
Paso 5 — Carga y transmite datos en el servidor
+page.server.ts/+layout.server.tspara bases de datos, archivos y env privados;+page.tsuniversal para APIs públicas que pueden ejecutarse en cualquier lugar.- El
loaddel servidor debe devolver datos que sobrevivan la serialización de devalue: JSON másDate,Map,Set,BigInt,RegExpy referencias cíclicas. Nada de funciones o instancias de clase. - Datos lentos y no esenciales: devuelve la promesa y usa
{#await}en la página — las promesas anidadas se transmiten a medida que se resuelven. Espera los datos esenciales antes de devolverlos para que estén en la primera pintura. - Usa
event.fetchdentro deload: hereda las cookies de la petición y en línea las respuestas para la hidratación en lugar de volver a buscarlas. - Después de la navegación,
datase actualiza sin recrear el componente: deriva de él ($derived) en lugar de copiarlo en locales no reactivos.
El SSR asíncrono (await en componentes) y las funciones remotas siguen siendo experimentales a partir de Svelte 5.57 / SvelteKit 2.7x (septiembre de 2026). No habilites compilerOptions.experimental.async en código de producción a menos que yo lo pida explícitamente; el estado exacto y la activación están en references/runes-ssr.md.
Paso 6 — Aplica las reglas de seguridad de SSR
- Los secretos se mantienen privados.
$env/static/private/$env/dynamic/privateson solo de servidor; solo las variables con prefijoPUBLIC_llegan al navegador. Las importaciones desde$lib/server/y*.server.jslanzan un error si un componente las incluye. - Todo lo que se devuelve desde
loadse incrusta en el payload HTML — todo, no solo los campos que renderizas. Nunca devuelvas hashes de contraseñas, tokens o campos internos que no necesites en el cliente. - CSP con nonces. Configura
kit.csp; SvelteKit inyecta un nonce en sus propios scripts en línea, y%sveltekit.nonce%cubre las etiquetas manuales enapp.html. Un servidor real puede enviar cabeceras CSP reales — úsalo en lugar de una etiqueta<meta>más débil. {@html}sigue siendo XSS. Renderizado en servidor no significa sanitizado. Sanitiza HTML no confiable con DOMPurify justo antes del renderizado.- La autenticación pertenece a
hooks.server.ts(poblalocals) más comprobaciones a nivel de ruta. Las funcionesloadde layout no se ejecutan en cada navegación del lado del cliente. - La protección CSRF está activada por defecto para envíos de formularios; déjalo activado y verifica el origen tú mismo en cualquier endpoint de mutación
+server.jspersonalizado. - Cookies de sesión:
httpOnly,secureen producción,sameSite: 'lax'como mínimo. - La
fetchdel lado del servidor de URLs proporcionadas por el usuario es SSRF. Lista blanca de hosts; bloquea IPs privadas y endpoints de metadatos de la nube.
Lista de verificación completa y código: references/security.md.
Revisando una aplicación existente
Cuando la tarea sea auditar o depurar en lugar de construir, trabaja a través de references/ssr-review.md: comprobaciones de modo de renderizado, runes invisibles en servidor, riesgos de hidratación, estado por petición, exposición de payload, búsquedas de seguridad y simulacros de fallos. Reporta hallazgos como archivo:línea con el fallo de producción que causa cada uno.
Archivos de referencia
references/runes-ssr.md— comportamiento rune por rune en servidor, patrones de seguridad de hidratación, estado por petición,svelte/serverrender +hydrateplano, y estado experimental del SSR asíncrono / funciones remotas.references/ssr-build.md— configuración de SvelteKit y adaptadores, opciones de página (ssr/csr/prerender), hooks, acciones de formulario, streaming, Vite SSR plano y notas de despliegue.references/ssr-review.md— lista de verificación de auditoría para aplicaciones SSR existentes: modos de renderizado, runes invisibles en servidor, riesgos de hidratación, ámbito de estado, exposición de payload, búsquedas de seguridad y simulacros de fallos.references/security.md— secretos y variables de entorno, nonces CSP, XSS, CSRF, manejo de autenticación/sesión, SSRF, fuga de errores y lista de verificación previa al lanzamiento.assets/— archivos de inicio copiables:svelte.config.js(SvelteKit con CSP),hooks.server.ts(autenticación más cabeceras de seguridad), eindex.html/entry-server.js/entry-client.jspara Vite SSR plano (el trío se construye en CI mediantetests/plain-ssr).