wandres.dev
ASSETS · CSS, imágenes, fuentes

public vs assets procesados: pipeline o copia literal

Cada archivo de un proyecto Vite toma uno de dos caminos: si tu código lo referencia entra al pipeline y sale con hash, quizá incrustado, deduplicado y con su URL reescrita; si lo dejas en public se copia tal cual a la raíz de salida, mismo nombre, sin hash ni proceso. La pregunta que decide el camino correcto no es el tamaño ni el tipo, sino quién es dueño de la URL final.

⏱ 15 min

Cada archivo de un proyecto Vite toma uno de dos caminos. Si tu código lo referencia, entra al pipeline: se le calcula un hash, quizá se incrusta, se deduplica y se reescribe su URL. Si lo dejas caer en public/, toma el otro camino: se copia tal cual a la raíz de salida, con el mismo nombre, sin hash y sin proceso alguno. Elegir el camino correcto para cada archivo es toda la lección, y la pregunta que lo decide no es el tamaño ni el tipo, sino una más honda: quién es dueño de la URL final.

🎯 Al terminar esta lección sabrás
  • Distinguir el asset procesado —que pasa por el grafo— del archivo en public/, que es copia literal.
  • Saber cómo se referencia cada uno: import o URL relativa frente a ruta absoluta.
  • Reconocer los casos legítimos de public/ y su precio: sin hash ni cache-busting.
  • Configurar publicDir y assetsInclude, y elegir la vía con un criterio claro.

Dos vías, dos destinos

Un asset procesado es aquel que tu código referencia —con un import, o con una URL relativa que Vite puede ver en una plantilla—. Vite lo resuelve, le pone huella y reescribe su referencia; acaba en assetsDir con un hash. Un archivo en public/ es lo opuesto: se copia sin tocar a la raíz de dist, se referencia con una ruta absoluta que escribes literal, y no lleva hash, no se procesa y no se incrusta jamás.

Asset procesado Archivo en public/
Cómo lo referencias import o URL relativa ruta absoluta literal
Lleva hash no
Puede incrustarse no
Vite valida que existe no
Dónde acaba assetsDir con hash raíz de dist, mismo nombre

La existencia de dos vías no es redundancia ni herencia histórica: responde a dos relaciones distintas entre un archivo y el mundo. El asset procesado tiene una relación interna —solo tu código lo nombra, así que el build puede renombrarlo a su antojo mientras mantenga la referencia coherente—. El archivo público tiene una relación externa —un rastreador, un navegador que pide el favicon, un tercero que valida una ruta— y esa relación fija el nombre desde fuera, fuera del alcance del bundler.

Esa diferencia se ve en la propia salida: lo procesado aterriza con hash bajo assets/, mientras que lo público reaparece intacto en la raíz.

dist/
├─ index.html            # entra al pipeline: Vite lo reescribe
├─ robots.txt            # public: copia literal, mismo nombre
├─ favicon.ico           # public: copia literal, mismo nombre
└─ assets/
   └─ hero.8b1d3e.avif   # procesado: renombrado con hash

Cómo se referencia cada una

Un asset procesado se referencia por import, o desde una plantilla que Vite analiza con una ruta relativa como ./hero.png. En desarrollo su URL cuelga de /src/...; en el build se convierte en /assets/hero.[hash].png. Un archivo público, en cambio, se referencia siempre por su ruta absoluta desde la raíz —/logo.png— que debes escribir exactamente: Vite no la reescribe, no comprueba que el archivo exista, y no le pone hash. En dev se sirve desde / y en el build se copia a la raíz de dist.

// Procesado: importas la fuente, recibes la URL del artefacto
import heroUrl from "./hero.png";
img.src = heroUrl; // -> /assets/hero.8b1d3e.png en el build

// Publico: escribes la ruta literal, Vite no la toca
nav.innerHTML = '<img src="/logo.png">';

Dos reglas cierran el modelo: nunca importes desde public/ —duplicarías el archivo, una copia procesada y otra literal— y nunca referencies un asset procesado por una ruta escrita a mano, porque perderías el hash y el archivo se moverá bajo tus pies.

El 404 que solo aparece en producción

El fallo más común de este nivel nace de mezclar las dos vías. En desarrollo, Vite sirve casi todo desde rutas planas, así que una ruta clavada a un asset de src a veces parece funcionar. En el build ese archivo se movió a assets/ con un hash, y la ruta clavada apunta a un sitio que ya no existe: 404 en producción, verde en local. La regla que lo evita es mecánica: si el archivo vive en src, impórtalo; si necesitas una ruta literal estable, va en public/.

// Trampa: ruta clavada a un asset de src. Parece ir en dev,
// 404 en el build porque el archivo se movio y llevo hash.
img.src = "/src/assets/hero.png";

// Correcto: import. Vite reescribe a /assets/hero.8b1d3e.png
import heroUrl from "./assets/hero.png";
img.src = heroUrl;

Cuándo public/ es la vía correcta

public/ existe para los archivos que deben conservar un nombre exacto, estable y conocido desde fuera del build: robots.txt, sitemap.xml, un manifest.webmanifest, el favicon.ico, los archivos de .well-known/, o el archivo de verificación que un tercero comprueba en una ruta fija. También sirve para los recursos que se referencian de un modo que Vite no puede ver estáticamente —una URL que tu código arma en runtime, o un nombre que envías como cadena a otro sistema— y para medios grandes que deliberadamente no quieres procesar ni hashear.

Los casos canónicos son fáciles de reconocer:

  • robots.txt y sitemap.xml, que un rastreador pide por su nombre exacto.
  • favicon.ico y manifest.webmanifest, que el navegador busca en rutas fijas.
  • La carpeta .well-known/, que protocolos de terceros exigen en una ruta literal.
  • Archivos de verificación, donde un servicio externo comprueba un nombre concreto.

El precio es concreto: sin cache-busting. Como el nombre no cambia nunca, debes servir los archivos públicos con una caché corta o aceptar el riesgo de servir una versión vieja; pierdes la garantía de caché inmutable y eterna de la segunda lección. Es un intercambio consciente, no un descuido, y la asimetría es exacta: renuncias al hash a cambio de un nombre que el mundo exterior puede fijar de antemano.

flowchart TB
F[un archivo del proyecto] --> Q[lo referencia tu codigo]
Q -->|si por import o url| P[entra al pipeline]
Q -->|no nombre externo fijo| U[carpeta public]
P --> P1[hash dedupe inline y reescritura]
U --> U1[copia literal a la raiz sin hash]
style P1 fill:#a6e3a1,color:#11111b
style U1 fill:#f9e2af,color:#11111b
📝
El favicon y el HTML de entrada son un caso mixto

El index.html de la raíz es especial: Vite lo procesa y reescribe las referencias relativas que encuentra, así que un <link rel="icon" href="./favicon.svg"> con ruta relativa entra al pipeline y se hashea. Si en cambio referencias /favicon.ico con ruta absoluta, Vite lo trata como archivo público y lo deja intacto. Elige según quieras cache-busting o un nombre fijo que herramientas externas esperan encontrar en la raíz.

publicDir y la base de despliegue

La carpeta pública es public por defecto, ajustable con publicDir. Y hay un matiz de despliegue que causa errores sutiles: una ruta pública clavada como /logo.png asume que vives en la raíz del dominio, así que se rompe si sirves la app bajo un subcamino. La forma correcta de referenciar un archivo público desde el código es a través de la base configurada.

// Mal: ruta clavada, se rompe si despliegas bajo /app/
const url = "/logo.png";

// Bien: respeta la base declarada en vite.config
const url = import.meta.env.BASE_URL + "logo.png";

assetsInclude y el criterio final

Por defecto Vite reconoce como assets un catálogo de extensiones. Si trabajas con formatos que no están en él —un modelo .gltf, un .glb— usa assetsInclude para que Vite los trate como recursos: así importarlos devuelve una URL y entran al pipeline, en lugar de fallar.

export default defineConfig({
  assetsInclude: ["**/*.gltf", "**/*.glb"],
});

Conviene entender que assetsInclude no procesa el formato —Vite no sabe optimizar un modelo 3D— sino que lo admite como asset de pleno derecho: le da URL, hash y participación en el grafo, tratándolo como bytes opacos que copiar y versionar. Es la puerta por la que cualquier extensión exótica se beneficia del cache-busting sin que Vite entienda su contenido.

⚙️

Deja que lo procese

Si tu código lo referencia y te beneficia el hash o el inlining: import, y el grafo hace el resto.

📥

Ponlo en public

Si debe conservar un nombre exacto conocido fuera del build, o se referencia de un modo que Vite no ve.

💡
Un árbol de decisión en una sola pregunta

Ante cualquier archivo, la decisión se resuelve en cadena. ¿Lo referencia tu código, o una plantilla que Vite analiza? Si no —lo pide un rastreador, un tercero, una URL de runtime—, va a public/. Si sí, ¿te beneficia que lleve hash y pueda incrustarse? Casi siempre, y entonces lo importas. La única excepción razonable es un medio enorme que no quieres versionar y prefieres servir por su nombre desde un CDN, pero eso ya no es un archivo de tu build sino un recurso externo con su propia vida.

La pregunta de verdad es quién es dueño de la URL

Toda decisión de assets en Vite se reduce a una sola pregunta: ¿quién es responsable de conocer la URL final de este archivo, el bundler o algo ajeno a él? Cuando tu código referencia un asset, le estás entregando al bundler esa responsabilidad, y a cambio él te da todo lo que el grafo habilita: un hash de contenido para que el archivo se cachee para siempre, deduplicación para que dos referencias compartan un artefacto, inlining cuando el archivo es diminuto, y reescritura automática para que nunca teclees la ruta final. Cedes el control del nombre y ganas corrección. public/ es la renuncia explícita a ese trato: le estás diciendo a Vite “yo, o alguien externo, soy dueño de esta URL; no la toques, no la renombres, no finjas entender cómo se usa”. Eso es exactamente lo correcto para un robots.txt que un rastreador pedirá por su nombre literal, un archivo de verificación que un tercero comprueba en una ruta fija, o una URL que tu servidor ensambla como cadena que el bundler no puede ver. Pero es exactamente lo incorrecto para cualquier cosa que tu propio código referencie, porque en el momento en que clavas /hero.png para esquivar un import, renuncias al hash —y ya no puedes cachear de forma inmutable, y debes temer las cachés obsoletas—, renuncias a la deduplicación, renuncias al inlining, y cargas con la contabilidad manual que el pipeline existía para abolir; y encima obtienes un archivo del que Vite ni siquiera te avisará cuando desaparezca, porque nunca supo que existía. Así que el modelo mental no es “los pequeños aquí, los grandes allá” ni “las imágenes se procesan, el texto va a public”: es una cuestión de autoridad sobre el nombre. Si el build es dueño de la URL, mételo al grafo y cosecha el hash y el cache-busting. Si el mundo exterior es dueño de la URL, ponlo en public/ y acepta, conscientemente, que has bajado del pipeline y vuelto a nombrar las cosas a mano. Casi todo bug de assets real en una app Vite —el 404 en producción que funcionaba en dev, la imagen obsoleta tras un despliegue, el icono que se esfuma bajo una base con subcamino— es alguien que respondió mal a esa pregunta de propiedad. Formúlala bien, una sola vez, por cada archivo, y la carpeta public/ deja de ser un cajón de sastre para convertirse en lo que es: la frontera declarada entre lo que el build gobierna y lo que el mundo exige por su nombre.

⚔️ Elige la vía por autoridad sobre el nombre
  1. Pon una imagen en public/ referenciada por /img.png y otra en src importada; compara las dos rutas de salida y cuál lleva hash.
  2. Renombra la fuente de ambas y observa: la procesada obtiene una URL con hash nueva sola; la pública conserva su nombre en silencio.
  3. Despliega bajo una base con subcamino y mira cómo se rompe la ruta pública clavada con /; arréglala con import.meta.env.BASE_URL.
  4. Añade un modelo .gltf, observa el error de Vite, y luego suma su extensión a assetsInclude para que el import devuelva una URL.
  5. Borra un archivo público aún referenciado por cadena y confirma que Vite no da aviso alguno, a diferencia de un import roto.