wandres.dev
CONFIGURAR VITE · plugins y resolve

resolve: alias, extensiones, conditions y dedupe

La clave resolve de Vite: cómo alias reescribe especificadores, cómo extensions y mainFields eligen archivo, cómo conditions selecciona variantes y cómo dedupe garantiza una sola copia, todo sobre el oxc-resolver de Vite 8.

⏱ 18 min

Cada import de tu código es una pregunta —qué archivo es este especificador— y la clave resolve es donde le dictas la respuesta a Vite. Sus ejes reescriben una parte concreta del algoritmo de resolución de Node: alias cambia el especificador antes de buscarlo, extensions decide qué probar cuando falta la extensión, conditions y mainFields eligen entre variantes de un paquete, y dedupe fuerza una copia única. En Vite 8 todo esto corre sobre el oxc-resolver escrito en Rust. Dominar estos cinco ejes es controlar la última costura entre tu import y el byte que ejecuta el navegador.

🎯 Al terminar esta lección sabrás
  • Reescribir especificadores con alias, en forma de objeto y de patrón.
  • Ajustar extensions y mainFields sabiendo su coste.
  • Seleccionar variantes de paquete con conditions.
  • Garantizar una sola copia de una dependencia con dedupe.

alias: reescribir el especificador

alias intercepta un especificador y lo sustituye por otro antes de resolverlo. Su uso más común es matar los imports relativos profundos: en vez de subir cuatro carpetas con puntos, defines un @ que apunta a src y escribes rutas absolutas desde la raíz del proyecto. La forma de objeto hace coincidencia por prefijo; conviene resolver la ruta a un absoluto real con las utilidades de URL de Node para que funcione igual en cualquier plataforma.

import { defineConfig } from "vite"
import { fileURLToPath, URL } from "node:url"

export default defineConfig({
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
      "@ui": fileURLToPath(new URL("./src/components", import.meta.url)),
    },
  },
})

Cuando necesitas más que un prefijo —capturar un patrón, reordenar el resultado, o desviar un paquete entero hacia otro— usas la forma de array, donde cada entrada tiene un find y un replacement, y find puede ser una expresión regular con grupos.

resolve: {
  alias: [
    { find: /^~(.+)/, replacement: "$1" },      // ~lib pasa a lib
    { find: "lodash", replacement: "lodash-es" }, // fuerza la variante ESM
  ],
}
⚠️
Un alias que apunta dentro de un paquete rompe su encapsulación

alias es potente y peligroso a partes iguales. Si lo usas para apuntar a un archivo interno de una dependencia, te saltas su campo exports, que es justo la frontera que el autor diseñó para protegerte de sus internals. Funciona hoy y se rompe en el próximo parche de la librería, sin que ningún tipo te avise. Reserva alias para tu código; para consumir paquetes, deja que sus exports y las conditions hagan su trabajo.

extensions, mainFields y conditions

extensions es la lista de sufijos que Vite prueba cuando el import los omite. El orden importa y tiene coste real: cada extensión es un intento de disco, y un import sin extensión que acaba en la sexta entrada pagó cinco accesos fallidos. Menos entradas resuelven más rápido, y escribir siempre la extensión en tus propios imports elimina el problema de raíz.

resolve: {
  extensions: [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"],
  mainFields: ["browser", "module", "jsnext:main", "main"],
  conditions: ["module", "browser", "development|production"],
}

mainFields y conditions deciden, ante un paquete con varias variantes, cuál servir. mainFields es el mecanismo veterano: prueba campos del package.json en orden —module para el ESM que quiere el bundler, browser para sustituir builds enteros— antes de caer en main. conditions es el mecanismo moderno: selecciona ramas dentro del campo exports. Fíjate en el token development|production: Vite lo resuelve a development cuando sirves y a production cuando construyes, de modo que una librería puede exponer una build con avisos en dev y una limpia en prod con una sola entrada.

Vite parte de un conjunto de conditions por defecto sensato —incluye module, browser y la pareja development con production— y lo que tú declaras en resolve.conditions se antepone a esa base en lugar de reemplazarla. Por eso rara vez necesitas listar las estándar: basta con añadir la tuya, como una condition internal que sirva el fuente de un paquete del monorepo sin compilarlo, y dejar que las demás sigan en su sitio.

Ese detalle —anteponer, no reemplazar— es fácil de pasar por alto y explica un fallo típico: alguien redefine resolve.conditions con una sola entrada creyendo que añade, y en realidad tira por la borda browser y module, con lo que medio ecosistema deja de resolver su variante correcta y aparecen errores que no tienen nada que ver con el import que se tocó.

🔀

alias

Reescribe el especificador antes de resolver. Para tu código; nunca para saltarte los exports de un paquete ajeno.

📁

extensions

Qué sufijos probar sin extensión. Cada entrada es un acceso a disco; menos es más rápido.

🧭

conditions

Elige la rama del campo exports. El token development|production cambia según sirvas o construyas.

🧬

dedupe

Fuerza una única copia de un paquete en todo el grafo. La cura del Invalid hook call.

dedupe resuelve un problema de correctitud, no de estética. Si dos partes de tu grafo acaban resolviendo dos copias físicas de React, tienes dos registros de hooks distintos y un componente que cruza la frontera revienta con el célebre Invalid hook call. Listar el paquete en dedupe obliga a Vite a colapsar todas las referencias a una única instalación, y el problema desaparece de raíz.

resolve: {
  dedupe: ["react", "react-dom"],
  preserveSymlinks: false,
}

El factor silencioso que acompaña a dedupe son los enlaces simbólicos. En un monorepo con pnpm o con workspace:, los paquetes locales son symlinks, y el resolutor debe decidir si identifica un módulo por su ruta real o por la del enlace. Con preserveSymlinks en false —lo habitual— sigue el enlace hasta el archivo real, que es justo lo que permite que dedupe reconozca dos referencias como la misma copia. Ponerlo en true puede reintroducir la duplicación que creías resuelta.

Conviene además saber que dedupe opera sobre nombres de paquete, no sobre rangos: colapsa a una sola copia física las referencias a react, pero no puede inventar una versión común si dos partes del grafo exigen mayores incompatibles. Cuando eso ocurre, el problema deja de ser de resolución y pasa a ser de versiones, y se arregla una capa más arriba, en los rangos declarados, con las mismas armas del nivel de los catalogs.

flowchart LR
A[import en tu codigo] --> B[resolve.alias reescribe]
B --> C[prueba resolve.extensions]
C --> D[lee package.json]
D --> E[elige por conditions y mainFields]
E --> F[dedupe fuerza copia unica]
F --> G[ruta final del modulo]
style F fill:#a6e3a1,color:#11111b
ℹ️
En SSR conviven dos pasadas de resolución

Una app con render en servidor resuelve dos veces: una para el cliente, que empaqueta para el navegador, y otra para el servidor, que a menudo deja dependencias externas para que Node las cargue. Si las conditions de ambas no están alineadas, acabas sirviendo el build de navegador en Node o al revés. En Vite declaras las del cliente en resolve.conditions y las del servidor en el entorno ssr; mantenerlas coherentes es una de las causas más comunes de bugs sutiles en SSR.

Los paths del tsconfig y el bundler

Hay un alias que Vite no ve por defecto: los paths del tsconfig.json. Los define TypeScript para su type-checker, pero el resolutor de Vite no los lee, así que un @/ que compila sin quejas en el editor puede fallar en el build si no lo replicas. Tienes tres salidas: duplicarlo a mano en resolve.alias, instalar un plugin que lea el tsconfig, o —lo preferible— migrar esos alias internos a los imports del package.json, que no necesitan ningún espejo.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

El caso más engañoso es el que compila y arranca en desarrollo pero rompe en el build de producción, porque el pre-bundle de dependencias y el empaquetado final no siempre comparten cada matiz de resolución. Si un import solo falla al construir, sospecha de un alias o un path que existe para el type-checker pero que el bundler nunca vio.

Cada mecanismo que eliminas es una fuente menos de desincronización entre el type-checker, el bundler y el runtime. De ahí la regla que cierra el eje de la resolución: minimiza los alias propietarios. Cuanto más delegues en exports, imports y las conditions estándar, menos configuración frágil tendrás que mantener sincronizada a mano, y más portable será tu proyecto entre herramientas. La config de resolución más robusta es, casi siempre, la más pequeña.

💡
Cuando una resolución te desconcierte, trázala

Ante un import que resuelve al archivo equivocado, no lo razones sobre el papel: instrumenta el resolutor real. Vite acepta la bandera de depuración para registrar la resolución de cada módulo, y los mensajes muestran qué alias, qué condition y qué extensión ganaron en cada paso. Ver el algoritmo real que corre, en vez del que crees que corre, resuelve en minutos lo que la deducción no resuelve en una tarde.

La resolución es la costura donde se pega todo el ecosistema

resolve parece un rincón de configuración menor y es, en realidad, el punto exacto donde el package manager, el formato de módulo, TypeScript, el navegador y el bundler tienen que ponerse de acuerdo sobre qué archivo cargar. Es una costura fina y crítica: un fallo aquí no da un error claro, da el módulo equivocado, y depurar el módulo equivocado es infinitamente peor que depurar un crash, porque el código parece correcto y el tipo cuadra. Por eso en 2026 la industria convergió en un motor común, el oxc-resolver escrito en Rust que Vite 8 comparte con Rspack: hacer la resolución una vez, rápida y correcta, y reutilizarla, tiene una consecuencia enorme, y es que un paquete que resuelve bien en Vite resuelve igual en cualquier herramienta que use el mismo motor. La lección profunda del nivel es que la configuración de resolución más robusta es casi siempre la más pequeña: cada alias propietario, cada mainFields a mano, es una fuente de desincronización entre el type-checker, el bundler y el runtime que tendrás que mantener sincronizada tú. Cuanto más delegues en exports, imports y las conditions estándar, menos config frágil arrastras. Dominar alias, extensions, conditions y dedupe no es memorizar cuatro claves: es entender que entre tu import y el byte que corre hay un algoritmo, que ese algoritmo es configurable, y que tocarlo con criterio —lo mínimo, en el eje correcto— es lo que separa un build que resuelve por suerte de uno que resuelve por diseño.

⚔️ Controla la resolución de tu proyecto
  1. Define un alias @ que apunte a src con fileURLToPath y comprueba que un import profundo se acorta y sigue resolviendo.
  2. Añade un alias de patrón con expresión regular y verifica que el grupo capturado aparece en el replacement.
  3. Recorta resolve.extensions al mínimo que use tu proyecto y escribe siempre la extensión en tus imports nuevos.
  4. Provoca a propósito dos copias de una librería con estado y arréglalo listándola en dedupe; confirma con el grafo que ahora hay una sola.
  5. Explica por qué apuntar un alias dentro de un paquete ajeno es una bomba de relojería.