wandres.dev
DEV VS BUILD · las dos mitades

Build de cliente vs build de SSR: dos salidas del mismo código

El build no es singular. Una app con SSR produce, del mismo fuente, al menos dos artefactos distintos: un build de cliente para el navegador —con assets hasheados y todo empaquetado— y un build de servidor para Node o el edge —con las dependencias externalizadas y sin hash—. Cómo el target y los externals proyectan un mismo código sobre runtimes opuestos, y cómo la Environment API generaliza la idea a N entornos.

⏱ 16 min

Acabas de aprender que dev y build son dos naturalezas. Ahora el giro: el propio build tampoco es uno. En cuanto tu app hace renderizado en servidor, un mismo fuente se compila en dos artefactos irreconciliables entre sí —un build de cliente que corre en el navegador y un build de SSR que corre en Node o en el edge—. No son dos proyectos: es el mismo código proyectado sobre dos runtimes por dos parámetros, el target y los externals. Comprender esta bifurcación es dejar de ver “el build” y empezar a ver una familia de builds gobernada por a quién le hablas.

🎯 Al terminar esta lección sabrás
  • Distinguir las dos salidas —cliente y SSR— y qué optimiza cada una.
  • Entender target como la declaración de a qué runtime le hablas.
  • Entender los externals: por qué el SSR deja dependencias fuera y el cliente las empaqueta.
  • Situar la Environment API como la generalización a N entornos del mismo grafo.

Dos salidas del mismo grafo

vite build produce, por defecto, el build de cliente: la salida a dist/ con assets hasheados, un manifest para la hidratación y el grafo troceado para el navegador. Cuando hay SSR, se ejecuta además vite build --ssr entrada.js, que produce un artefacto completamente distinto: un punto de entrada de servidor, sin hashes en los nombres —porque no hay caché de CDN que invalidar— y pensado para importarse desde un runtime de JavaScript en el servidor.

El fuente es el mismo; la salida diverge porque el destino diverge. El navegador no tiene node_modules en tiempo de ejecución, así que el cliente debe empaquetar todo lo que necesita. El servidor sí tiene node_modules a mano, así que el SSR puede dejar sus dependencias fuera del bundle y resolverlas en runtime. De esa sola diferencia de contexto brotan casi todas las decisiones que separan a las dos salidas.

El manifest del build de cliente merece una mención, porque es el hilo que cose las dos salidas. El servidor renderiza el HTML, pero necesita saber qué archivos de cliente —con sus hashes— inyectar en cada página para que la hidratación encuentre su código. Ese mapa de módulo a artefacto hasheado es el manifest: la pieza que permite que un artefacto de servidor sin hash sepa referenciar a un artefacto de cliente con hash. Dos salidas independientes, un puente declarativo entre ellas.

Cada salida deja en disco cosas distintas:

  • Cliente: HTML, chunks de JS con hash, CSS extraído, assets y un manifest.
  • SSR: un entry de servidor importable, sin hash y sin HTML servible por sí solo.
  • Cliente: todo autocontenido, porque el navegador no resuelve node_modules.
  • SSR: las referencias externas intactas, porque el servidor sí las resuelve en runtime.
🌐

Build de cliente

Target navegador. Empaqueta las dependencias, hashea los assets, genera manifest para hidratar. Vive en un CDN y lo descarga el usuario.

🖥️

Build de SSR

Target Node o edge. Externaliza dependencias, no hashea, produce un entry de servidor. Vive en tu runtime y renderiza el HTML.

target: a quién le hablas

El target declara el runtime al que va dirigido el artefacto, y cambia qué sintaxis se rebaja, qué polyfills se inyectan y qué globals se asumen. El build de cliente apunta a una línea base de navegadores —fijada por build.target o por tu browserslist— y asume window, document y las APIs del DOM. El build de SSR apunta a una versión de Node o a un runtime de edge, asume globalThis y las APIs del servidor, y jamás debería tocar el DOM.

// vite.config.ts: dos objetivos distintos para dos runtimes
export default defineConfig({
  build: { target: "es2022" },        // linea base del navegador (cliente)
  ssr: { target: "node" },            // o "webworker" para el edge
});

import.meta.env.SSR es la señal que tu código lee para saber en qué lado se está compilando y ejecutando, igual que import.meta.env.DEV distinguía la naturaleza anterior. Un mismo componente puede así evitar tocar window cuando import.meta.env.SSR es verdadero, y el bundler puede podar la rama que no corresponde a cada salida.

Rebajar la sintaxis según el target no es cosmético: un target de navegador antiguo obliga a transpilar sintaxis moderna e inflar el bundle con helpers, mientras que un target de Node reciente puede dejar pasar casi todo sin tocar. Elegir un target demasiado conservador en el cliente es una causa habitual de bundles innecesariamente grandes; elegir uno demasiado agresivo es una causa de errores en runtimes viejos. El target es, en el fondo, un contrato sobre qué sabe ejecutar tu destino.

⚠️
El mismo módulo, dos entornos, dos globals

Un componente que accede a window en el nivel superior funciona en el cliente y estalla en el SSR, donde window no existe. La disciplina es acceder a las APIs específicas del entorno de forma perezosa —dentro de un efecto o tras comprobar import.meta.env.SSR— para que el mismo módulo sea válido en ambas salidas. Escribir código isomorfo es, en esencia, escribir código que no supone un único runtime.

externals: qué NO empaquetar

Aquí está la decisión más característica del build de SSR. Por defecto, Vite externaliza las dependencias en el build de servidor: no las mete en el bundle, sino que deja los import intactos para que el runtime los resuelva desde node_modules al ejecutarse. El resultado es un artefacto de servidor pequeño y rápido de construir, que no reempaqueta media galaxia de paquetes en cada build ni intenta inlinear los módulos internos de Node.

// Controlar la frontera de externalizacion en SSR
export default defineConfig({
  ssr: {
    noExternal: ["paquete-solo-esm"], // forzar a empaquetar este
    external: ["pesado-nativo"],       // forzar a dejar este fuera
    target: "node",
  },
});

Hay dos razones para cruzar esa frontera con ssr.noExternal. Una: paquetes que solo publican ESM y que un runtime de servidor concreto no resolvería bien externalizados, así que conviene empaquetarlos. Otra, más decisiva en 2026: el despliegue al edge, donde no hay node_modules en tiempo de ejecución. Un worker o una función de edge necesita un bundle autocontenido, así que ahí el SSR se comporta más como el cliente: hay que empaquetarlo casi todo.

Fuerzas ssr.noExternal sobre un paquete en casos concretos:

  • Publica solo ESM y tu runtime de servidor no lo resuelve bien externalizado.
  • Despliegas al edge, donde no hay node_modules y el bundle debe bastarse a sí mismo.
  • Necesitas la misma copia que el cliente para no duplicar estado en memoria.
  • Publica sintaxis que tu target no acepta y hay que transpilarla al empaquetar.

Por debajo de externals actúa una capa más fina: las conditions del campo exports. Un mismo paquete puede entregar un archivo distinto según quién lo importe, y cada salida activa las condiciones que le corresponden —browser en el cliente, node o worker en el servidor—. Así, el build de cliente puede recibir la versión de navegador de una librería y el de SSR la de Node, del mismo paquete y sin que tú cambies un solo import.

{
  "exports": {
    ".": {
      "browser": "./dist/index.browser.mjs",
      "worker": "./dist/index.edge.mjs",
      "node": "./dist/index.node.mjs"
    }
  }
}

La Environment API generaliza la idea

Si dos salidas ya complican el modelo, Vite 8 lo lleva a su conclusión natural: no dos, sino N entornos. La Environment API modela cada destino —cliente, SSR en Node, edge en un worker— como un entorno con su propia configuración de resolución, target y externals, todos derivados del mismo grafo y orquestados por el mismo Vite.

// vite.config.ts: varios entornos, un solo grafo y un solo motor
export default defineConfig({
  environments: {
    client: { build: { target: "es2022" } },
    ssr: { resolve: { conditions: ["node"] } },
    edge: { resolve: { conditions: ["worker"] } },
  },
});

Dejas de pensar en “el build” y empiezas a pensar en un build parametrizado que se instancia una vez por runtime. Los meta-frameworks —Astro, Nuxt, SvelteKit— usan esta API para orquestar sus salidas sin reinventar la coordinación cada uno por su lado, y es la razón de que la unificación de Rolldown del nivel anterior importe tanto: un solo motor bajo N entornos.

flowchart TD
S[Un mismo codigo de app] --> C[Build de cliente]
S --> V[Build de SSR]
C --> C1[target navegador]
C --> C2[empaqueta las dependencias]
C --> C3[assets con hash y manifest]
V --> V1[target Node o edge]
V --> V2[deja dependencias externas]
V --> V3[entry de servidor sin hash]
style S fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b
style V fill:#cba6f7,color:#11111b
El código es una especificación; target y externals lo proyectan sobre un runtime

La idea que debes internalizar es que tu código fuente no es un programa para un runtime concreto: es una especificación que varios runtimes pueden realizar de formas distintas. El build de cliente y el de SSR no son dos programas: son dos proyecciones de la misma especificación, y los parámetros que definen cada proyección son el target —a qué runtime le hablas—, los externals —qué resuelves en tiempo de compilación frente a tiempo de ejecución— y las conditions del campo exports —qué archivo de cada dependencia entra en cada salida—. Cuando ves el build así, la aparente complejidad se ordena: no hay que memorizar reglas sueltas sobre SSR, basta preguntar en cada punto quién ejecutará esto y qué tendrá disponible cuando lo haga. El navegador no tiene node_modules, luego el cliente empaqueta; el servidor de Node sí los tiene, luego el SSR externaliza; el edge no los tiene, luego el SSR para edge vuelve a empaquetar. Todo se deduce del contexto de ejecución. Y aquí se cierra el arco del nivel entero: empezamos con dos naturalezas, dev y build; descubrimos que build se bifurca en cliente y servidor; y llegamos a la Environment API, que revela que no hay un número mágico de naturalezas, sino un grafo único proyectado sobre tantos runtimes como tu app necesite. La madurez en esto no es saber más comandos, sino dejar de preguntar “cómo hago el build” y empezar a preguntar “para qué runtime, con qué disponible, y por tanto qué se empaqueta y qué se externaliza”. El resto es consecuencia.

⚔️ Compila el mismo código para dos runtimes
  1. En una app con SSR, ejecuta el build y localiza las dos salidas: la de cliente con assets hasheados y la de servidor sin hash.
  2. Añade un acceso a window en el nivel superior de un módulo compartido y observa cómo el build de SSR falla mientras el de cliente no.
  3. Protege ese acceso con import.meta.env.SSR y confirma que ahora ambas salidas compilan.
  4. Fuerza ssr.noExternal sobre una dependencia y compara el tamaño del bundle de servidor antes y después.
  5. Explica en dos frases por qué el mismo SSR externaliza para Node pero empaqueta para el edge.