Vite en lib mode: empaquetar una librería con build.lib
Vite es célebre como dev server de aplicaciones, pero esconde un segundo modo que reorienta todo su pipeline hacia la publicación de librerías. El interruptor `build.lib` cambia la unidad de trabajo del HTML al punto de entrada, elige formatos de salida pensados para consumidores y no para un navegador, y externaliza las peer dependencies en vez de incluirlas. Cuándo ese modo es la herramienta correcta —librerías de componentes con CSS, proyectos que ya viven en Vite— y cuándo una utilidad pura pide algo más ligero como tsup o tsdown.
Conoces Vite como el dev server que sirve ESM nativo y arranca al instante, y como el que empaqueta tu aplicación para producción. Pero la misma herramienta guarda un segundo modo, deliberadamente distinto, para cuando lo que publicas no es una app sino una librería. El interruptor es un único bloque de configuración, build.lib, y al activarlo Vite deja de pensar en un HTML que carga scripts y empieza a pensar en un punto de entrada que otros van a importar. Entender qué cambia exactamente al pulsar ese interruptor es entender cuándo Vite es la elección correcta para empaquetar una librería y cuándo estás usando un martillo para una tarea de bisturí.
- Activar el modo librería de Vite con el bloque
build.liby ver qué reorienta. - Dominar los formatos de salida, sus valores por defecto y el papel de
name. - Externalizar las peer dependencies con
externalyoutput.globals. - Decidir cuándo Vite es la herramienta correcta y cuándo pide algo más ligero.
El interruptor build.lib
En su modo normal, Vite parte de uno o varios archivos HTML: son la raíz del grafo, y desde sus etiquetas de script Vite descubre todo el código de la aplicación. En modo librería esa premisa desaparece. Al declarar build.lib, le dices a Vite que la raíz ya no es una página sino un punto de entrada de módulo —tu src/index.ts—, y que el artefacto no es una app lista para desplegar sino un paquete listo para que otro proyecto lo importe.
Ese cambio de premisa arrastra varias consecuencias. Vite deja de inyectar el HTML, no aplica el hashing de nombres pensado para el cacheado de un navegador, y produce archivos con nombres estables y predecibles que tu package.json pueda referenciar. La configuración mínima es sorprendentemente breve:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: "src/index.ts",
name: "MiLib",
fileName: "mi-lib",
},
},
});
entry: el punto de entrada del grafo, o un mapa de varias entradas para exponer subrutas.name: el nombre del global, obligatorio solo si emitesumdoiife.fileName: la base del nombre de archivo; Vite le añade la extensión según el formato.formats: el array de formatos a emitir, con un valor por defecto que conviene conocer.
Formatos de salida y dependencias externas
El campo build.lib.formats controla en qué idiomas sale tu grafo, y su valor por defecto depende de si diste name. Con un name presente, Vite emite ['es', 'umd'], asumiendo que quieres además un artefacto que corra en un <script> suelto. Sin name, o con varias entradas —donde umd e iife no tienen sentido—, el defecto razonable es ['es', 'cjs']. La regla es la que ya conoces de los output formats de Rollup: umd e iife exponen un global y por eso exigen name.
La segunda decisión crítica es qué NO incluir. Una librería no debe empaquetar sus peer dependencies: si tu componente usa React, quieres que use el React de la aplicación que te consume, no una copia tuya. Por eso marcas esas dependencias como externas y, para los formatos de navegador, das el mapa de globals:
export default defineConfig({
build: {
lib: { entry: "src/index.ts", name: "MiLib", formats: ["es", "umd"] },
rollupOptions: {
external: ["react", "react-dom"],
output: { globals: { react: "React", "react-dom": "ReactDOM" } },
},
},
});
Aquí aparece la ventaja diferencial de Vite frente a un bundler pelado: los assets. Si tu librería importa CSS, Vite lo extrae a un archivo acompañante sin que configures nada, y lo mismo con imágenes o fuentes. Para una librería de componentes, donde el estilo viaja con el código, eso es justo lo que quieres y lo que un empaquetador de solo JavaScript no te da de serie. Los tipos, en cambio, no los genera Vite: se delegan en un plugin como vite-plugin-dts, que emite los .d.ts en paralelo al build.
La regla de qué dejar fuera del bundle es estable y conviene fijarla de una vez:
- peerDependencies: externas siempre; el consumidor aporta su propia copia.
- dependencies: se empaquetan o se externalizan según su tamaño y cuánto pesen.
- devDependencies: nunca viajan al artefacto publicado.
external: el campo donde declaras esa decisión, conoutput.globalspara el navegador.
Es fácil sorprenderse de que Vite emita umd sin habérselo pedido: ocurre porque diste name, y Vite interpreta esa presencia como que quieres un artefacto de navegador. Si solo publicas para bundlers y Node, omite name y fija formats a ['es'] o ['es', 'cjs'] de forma explícita. Ser explícito con formats es siempre mejor que confiar en un defecto que cambia de forma según otro campo: dejas tu intención por escrito y evitas emitir un umd que nadie va a cargar.
Cuándo Vite es la herramienta correcta
Vite en lib mode brilla en un perfil concreto de librería, y no en todos. Encaja cuando tu paquete lleva CSS, imágenes o cualquier asset que quieras que Vite procese y extraiga; cuando ya usas Vite para la aplicación y quieres un solo toolchain, una sola config y un solo ecosistema de plugins; y cuando publicas componentes de un framework —Vue, React, Svelte— cuyos plugins de Vite ya resuelven la compilación de sus archivos.
No encaja tan bien para una utilidad de TypeScript pura, sin assets ni framework: ahí Vite es más máquina de la que necesitas, y un empaquetador dedicado como tsup o tsdown arranca más rápido, se configura en menos líneas y no arrastra la maquinaria de un dev server que no vas a usar. La pregunta operativa es sencilla: ¿tu librería tiene algo más que TypeScript? Si la respuesta es CSS o componentes, Vite; si es solo lógica, algo más fino.
flowchart TD Q[que vas a publicar] --> C[libreria de componentes con CSS] Q --> U[utilidad TypeScript pura] Q --> A[la app ya usa Vite] C --> V[Vite en lib mode encaja] A --> V U --> T[tsup o tsdown son mas ligeros] V --> D[usa el plugin dts para los tipos]
En 2026 hay un matiz que inclina más la balanza hacia Vite cuando ya dudabas: Vite 8 empaqueta con Rolldown, el bundler en Rust, así que el build de librería hereda su velocidad y acorta la distancia de rendimiento que antes justificaba salir corriendo hacia esbuild. Vite sigue siendo más herramienta que un empaquetador puro, pero esa herramienta de más ya no cuesta tanto tiempo como costaba con Rollup en JavaScript.
Entradas múltiples y la forma del dist
Una librería seria rara vez expone un solo punto de acceso. Quieres que el consumidor importe mi-lib para lo principal y mi-lib/utils para una subruta concreta, y para eso build.lib.entry acepta un objeto que mapea cada nombre de salida a su archivo fuente. Cada entrada produce su propio artefacto, y el package.json las conecta a través del campo exports, la pieza que ya estudiaste y que aquí cobra sentido práctico.
// varias entradas: cada una es una subruta publicable
export default defineConfig({
build: {
lib: {
entry: {
index: "src/index.ts",
utils: "src/utils/index.ts",
},
formats: ["es"],
},
},
});
Con varias entradas, umd e iife dejan de tener sentido —un único global no puede representar varios puntos de acceso a la vez—, así que te quedas con es y, si hace falta, cjs. En el dist verás un archivo por entrada más los fragmentos compartidos que Vite extrae cuando dos entradas usan el mismo código interno. Ese troceado no es un accidente: evita duplicar en utils lo que ya vive en index, y deja que el consumidor cargue solo la parte que necesita.
entrycomo objeto: cada clave es un nombre de salida; cada valor, su archivo fuente.- Solo formatos de módulo: con varias entradas usas
esy opcionalmentecjs; los de navegador quedan fuera. - Fragmentos compartidos: el código común entre entradas se extrae una vez, sin duplicarse.
exportslas cablea: cada subruta delpackage.jsonapunta al artefacto de su entrada.
Publicar varias entradas es también una decisión de superficie de API: cada subruta que expones es un compromiso que tendrás que mantener, porque exponer mi-lib/utils es prometer que esa ruta seguirá existiendo. Empieza con las entradas que de verdad quieras sostener y añade más solo cuando un consumidor las pida.
Cuando emites varios formatos, fileName admite una función que recibe el formato y el nombre de la entrada y devuelve la ruta, en lugar de una cadena fija. Así garantizas que el es salga como .mjs y el cjs como .cjs sin que dos artefactos peleen por el mismo archivo, y que cada entrada conserve su nombre en el dist. Es un detalle invisible hasta que publicas un paquete dual con subrutas y descubres una colisión: entonces esa función es justo lo que mantiene ordenado el dist y sin ambigüedades el exports.
build.lib
El interruptor que reorienta Vite del HTML al punto de entrada. Sin él, Vite empaqueta una app; con él, empaqueta un paquete.
formats
es y umd por defecto si hay name; es y cjs si no. Sé explícito y emite solo lo que un consumidor real va a cargar.
external
Las peer dependencies se marcan externas para no duplicar React o Vue. En navegador, output.globals traduce el especificador al global.
Assets incluidos
La ventaja frente a un bundler pelado: Vite extrae CSS y procesa imágenes sin configurar nada. Ideal para librerías de componentes.
Cuesta poco caer en el error de pensar que Vite tiene dos personalidades, una para apps y otra para librerías, y que build.lib activa un programa distinto. La verdad es más elegante y más útil: es el mismo grafo de módulos, el mismo bundler debajo, la misma cadena de plugins; lo único que cambia es la premisa sobre quién está del otro lado. En modo app, del otro lado hay un navegador y un usuario, así que Vite emite HTML, hashea nombres para el cacheado y optimiza la carga inicial. En modo librería, del otro lado hay otro desarrollador y otro build, así que Vite emite formatos de módulo, nombres estables y deja fuera las peer dependencies para no pisar las del consumidor. Reconocer que es un solo motor reorientado, y no dos herramientas, te da dos cosas. La primera es que todo lo que aprendiste del bundler —output formats, external, tree shaking, chunks— se transfiere intacto: lib mode no es material nuevo, es el material de siempre apuntado a otro objetivo. La segunda es criterio para decidir: si vas a usar el motor entero de Vite —sus plugins, su procesado de assets, su compilación de componentes— lib mode te da todo eso por el precio de una config; pero si solo necesitas transpilar y empaquetar TypeScript, estás encendiendo un motor de doce cilindros para dar la vuelta a la manzana. La madurez no es saber usar lib mode, sino saber cuándo la potencia que trae compensa y cuándo un empaquetador de una sola función es la respuesta más honesta.
- Crea una librería mínima con una función y un componente, y configura
build.libconentry,nameyfileName. - Emite
esyumd, abre elumdy localiza dónde expone el global que definiste enname. - Añade un import de CSS al componente y comprueba que Vite extrae un archivo de estilos acompañante sin configuración extra.
- Marca
reactcomoexternal, dale su entrada enoutput.globalsy verifica que el bundle lo referencia en vez de incluirlo. - Instala
vite-plugin-dts, genera los.d.tsy razona por qué los tipos son una responsabilidad separada del empaquetado del JavaScript.