Instalar una PWA sin beforeinstallprompt: el elemento install y navigator.install()

Si alguna vez pusiste un botón de "Instalar la app" en una PWA, ya conocés el ritual. Escuchás beforeinstallprompt, le hacés preventDefault(), guardás el evento en una variable global, mostrás tu botón, y cuando el usuario hace click llamás a prompt(). Si el evento nunca llega (porque el navegador decidió que no, porque el manifest cargó tarde, porque es Safari), tu botón no aparece y no tenés forma de saber por qué.
beforeinstallprompt nunca fue un estándar. Estuvo en la spec de Web App Manifest y se sacó en 2020 porque solo lo implementaba Chromium, que lo siguió manteniendo por su cuenta. Hoy hay un intento de volver a estandarizarlo, y en esa discusión es donde WebKit fijó su posición (más abajo).
Chrome 158, que hoy está en beta y llega a estable el 17 de noviembre, trae el reemplazo: el elemento <install> y el método navigator.install(). Los dos hacen lo mismo, pero piden permiso de forma distinta, y esa diferencia es lo más interesante de la feature.
Lo mínimo
Para ofrecer la instalación de tu propia app:
<link rel="manifest" href="/manifest.webmanifest">
<install>
<a href="/como-instalar">Cómo instalar la app</a>
</install>
El navegador renderiza un botón propio. Lo que pongas adentro es fallback y solo se ve en navegadores que no conocen el elemento. En Chrome los hijos no se renderizan ni forman parte del árbol de accesibilidad, y el nombre accesible del botón lo pone el navegador, según el idioma de la página.
Si querés probarlo antes de que llegue la 158, en Chrome 157 se puede habilitar con --enable-blink-features=InstallElement,WebAppInstallation. Yo lo probé así: el elemento renderiza su botón, el link de adentro queda con ancho cero y navigator.install() sin un click del usuario tira NotAllowedError. El diálogo de instalación en sí no lo pude probar de esa forma.
La versión imperativa:
installButton.addEventListener('click', async () => {
try {
await navigator.install();
} catch (err) {
console.log(err.name); // AbortError, DataError, NotAllowedError, SecurityError…
}
});
Sin argumentos, las dos instalan la app del documento actual. Hay una diferencia con beforeinstallprompt incluso acá: tu manifest tiene que declarar id y start_url, o falla con DataError. Pero lo realmente nuevo es poder instalar otra app.
Instalar apps de otro origen
<install manifest="https://mail.suite.example/manifest.webmanifest">
<a href="https://mail.suite.example/">Abrir Mail</a>
</install>
await navigator.install({
manifest: 'https://mail.suite.example/manifest.webmanifest',
});
Esto habilita casos que hasta ahora no tenían solución: un catálogo de apps internas de una empresa, una suite que ofrece instalar la app hermana desde la principal, o un buscador o directorio de PWAs.
Si probaste el origin trial, la forma cambió. La versión de prueba recibía la URL de una página (navigator.install(install_url, manifest_id), y <install installurl> en el elemento). El navegador cargaba esa página en una pestaña invisible para descubrir el manifest. El feedback del trial fue malo por varios lados: los redirects de login o de idioma rompían la URL, cargar un documento entero era caro en dispositivos chicos, y la página destino recibía cookies antes de que el usuario aceptara nada. Así que se rediseñó para recibir la URL del manifest. Muchos artículos de principios de este año todavía muestran la forma vieja.
Dos modelos de confianza
El elemento y la API hacen lo mismo, pero no piden permiso de la misma manera:
| Diálogos que ve el usuario | |
|---|---|
navigator.install() sin argumentos |
Confirmación de instalación |
navigator.install({ manifest }) |
Permiso "este sitio quiere instalar apps" + confirmación |
<install> con o sin manifest |
Confirmación de instalación |
navigator.install({ manifest }) pide un permiso nuevo, web-app-installation, asociado al origen que instala. Y lo pide siempre que pasás un manifest, incluso si es del mismo origen. El usuario ve dos diálogos: primero el permiso, después la confirmación.
<install> no lo pide nunca. Según la spec, funciona incluso si el usuario había denegado ese permiso antes. La razón es que un click sobre un botón que dibuja el navegador (con estilo controlado, sin posibilidad de taparlo o disfrazarlo) es una señal de intención mucho más fuerte que un click cualquiera en la página seguido de una llamada a una API.
Si leíste el post sobre capability elements, es la misma idea llevada un paso más allá. Ahí los elementos mejoraban el timing del prompt de permisos y te entregaban el dato. Acá el elemento directamente reemplaza al permiso, porque el navegador confía más en un botón que dibuja él que en cualquier código que escribas vos.
En la práctica, para un catálogo, el elemento da una experiencia bastante mejor. Y como el evento de resultado burbujea, alcanza con un listener en el contenedor:
<ul id="catalog">
<li data-app="Mail">
Mail
<install manifest="https://mail.suite.example/manifest.webmanifest">
<a href="https://mail.suite.example/">Abrir Mail</a>
</install>
</li>
<li data-app="Calendario">
Calendario
<install manifest="https://cal.suite.example/manifest.webmanifest"
manifestid="https://cal.suite.example/?source=pwa">
<a href="https://cal.suite.example/">Abrir Calendario</a>
</install>
</li>
</ul>
<p id="status" role="status"></p>
<script>
const status = document.getElementById('status');
document.getElementById('catalog').addEventListener('installresult', (e) => {
const app = e.target.closest('li').dataset.app;
if (e.result === 'success') status.textContent = `${app}: listo.`;
if (e.result === 'invalid-data') console.error(`${app}: revisá el manifest`);
// 'aborted': el usuario canceló o el navegador no pudo. No insistas.
});
</script>
El role="status" está a propósito: la spec aclara que el evento no anuncia nada a los lectores de pantalla, así que el feedback lo tenés que dar vos.
Con la API hay otro tema con el permiso. Consultar el estado con navigator.permissions.query({ name: 'web-app-installation' }) dentro del handler del click funciona (lo probé y la query tarda unos milisegundos), pero install() necesita transient activation, que dura unos pocos segundos, y si en el medio metés un fetch o algo lento te podés pasar. Prefiero consultarlo al renderizar y esconder el botón si está en denied.
Tu manifest ahora es una API pública
Con la instalación por URL de manifest, un archivo que hasta ahora solo leía el navegador cuando alguien entraba a tu sitio pasa a ser un endpoint que otros sitios linkean. Y tiene reglas estrictas (las dos primeras aplican también a instalar tu propia app):
idystart_urltienen que estar declarados explícitamente. No alcanza con los valores que el navegador calcula por default. Según el explainer, en 2024 solo un 4% de los manifests declarabaid. Si el manifest de otra app no lo tiene, podés pasar el id calculado conmanifestid(lo ves en DevTools, en Application > Manifest).- Nada de redirects. Cualquier redirect hace fallar la instalación. Revisá trailing slashes, redirects de
httpahttps, elcleanUrlsde tu hosting y CDNs que redirigen según el idioma. - El
Content-Typetiene que ser JSON (application/manifest+jsonoapplication/json). Context/plainfalla en 158; si probás en 157 con flags, ese chequeo todavía no está. start_urltiene que ser del mismo origen que el manifest. No podés servir el manifest desde un CDN en otro dominio.- Cuenta la CSP del que instala. El fetch del manifest respeta el
manifest-srcdel sitio que tiene el botón. Un catálogo condefault-src 'self'no puede instalar nada de afuera hasta que agregues esos orígenes. - No uses hash en el nombre del manifest. Si tu build genera
manifest.3f2a.json, cada deploy rompe todos los links de instalación de terceros.
Del lado de la privacidad, el fetch del manifest va sin cookies, sin referrer y sin pasar por el service worker del destino. No necesita CORS, porque el destino no expone nada que no sea público.
El resto de la validación es razonable: tiene que haber un name o short_name, y al menos un ícono con purpose que incluya any. No se exige service worker ni soporte offline.
{
"id": "/",
"start_url": "/",
"name": "Mail",
"icons": [{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }],
"display": "standalone",
"related_applications": [{ "platform": "webapp", "id": "/" }]
}
Aunque no pienses usar ninguna de estas APIs, agregar id y start_url explícitos a tu manifest hoy es gratis y te ahorra problemas el día que alguien quiera instalar tu app desde otro lado.
Lo que la API no te va a decir, a propósito
Los resultados son poco precisos a propósito:
successno significa "se instaló". Si la app ya estaba instalada, el navegador puede ofrecer abrirla, y si el usuario acepta, el resultado también essuccess. No sirve para medir instalaciones nuevas.AbortErrorcubre casi todo: el usuario canceló, denegó el permiso, hay una política enterprise que lo bloquea, está en una ventana de incógnito, ya hay otra instalación en curso en esa pestaña. La spec deja la taxonomía de errores como un tema abierto.DataErrorno distingue la causa: red caída, 404, redirect, JSON inválido oidque no coincide dan todos el mismo error. Si el manifest es tuyo, DevTools te muestra el detalle en el panel Issues. Si es de otro origen, nada.- No hay forma de saber si una app de otro origen ya está instalada.
getInstalledRelatedApps()en desktop solo detecta apps cuyo scope incluye la página.
Es incómodo para el que construye la UI, pero tiene sentido. Si el resultado distinguiera "instalada" de "ya estaba", cualquier sitio podría sondear qué apps tenés instaladas. Lo mismo con el incógnito: falla siempre con aborted, pero después de validar los datos, para no dar una diferencia de timing que permita detectarlo.
Además hay restricciones de contexto. Solo funciona en el documento principal: nada de iframes (ni con allow), sandbox ni fenced frames. Si querés poner el botón dentro de un widget embebido, no se puede. Y el <install> hereda las restricciones de los capability elements: el navegador controla cómo se ve, valida que esté visible y no tapado, y lo bloquea un instante cuando recién se insertó o se movió. Si algo no cumple, el botón queda deshabilitado en silencio. El motivo lo podés leer con isValid e invalidReason (y enterarte cuando cambia con el evento validationstatuschange); dentro de un iframe, por ejemplo, vas a ver illegal_subframe.
Progressive enhancement
Por ahora esto funciona solo en Chrome 158 desktop (Windows, macOS, Linux y ChromeOS). Android llega más adelante, y WebView no tiene planes. Así que beforeinstallprompt todavía tiene un rato de vida:
// beforeinstallprompt se dispara una sola vez y temprano: hay que capturarlo apenas carga el módulo
let deferredPrompt;
addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
});
const INSTALLED = '(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)';
async function setupInstall(container) {
// Ya estamos adentro de la app instalada
if (matchMedia(INSTALLED).matches) return;
// Solo responde si el manifest se lista a sí mismo en related_applications
const related = (await navigator.getInstalledRelatedApps?.()) ?? [];
if (related.some((app) => app.platform === 'webapp')) return;
if ('HTMLInstallElement' in window) {
container.append(document.createElement('install'));
return;
}
// Defensivo: en Chrome 158 la API y el elemento salen juntos
if ('install' in navigator) {
const button = Object.assign(document.createElement('button'), { textContent: 'Instalar' });
button.onclick = () => navigator.install().catch(() => {});
container.append(button);
return;
}
// Chromium sin las APIs nuevas, por ejemplo Android
if (deferredPrompt) {
const button = Object.assign(document.createElement('button'), { textContent: 'Instalar' });
button.onclick = async () => {
button.remove(); // prompt() se puede llamar una sola vez
await deferredPrompt.prompt();
deferredPrompt = null;
};
container.append(button);
}
}
El elemento va primero porque es el que el navegador dibuja y valida. Para la app actual, ni el elemento ni navigator.install() sin argumentos piden permiso, pero el <install> no depende de que tu botón pase los chequeos de activación. El beforeinstallprompt se captura afuera de la función porque se dispara una sola vez, temprano en la carga: si registrás el listener después de un await, en Android muchas veces ya pasó y el botón no aparece nunca. En Safari y Firefox no va a pasar nada, y lo razonable ahí es un link a instrucciones o directamente no mostrar nada.
Sobre getInstalledRelatedApps(): no adivina nada. Para que sirva, tu manifest se tiene que listar a sí mismo en related_applications ({ "platform": "webapp", "id": "<tu id>" }), y en desktop solo responde si la página está dentro del scope de la app.
Si no necesitás nada de esto, el <install> con un link de fallback adentro ya cubre el caso del navegador moderno y el del que no lo soporta, sin una línea de JavaScript.
El debate
La feature viene con pelea incluida.
WebKit se opone formalmente (la posición está en el issue sobre beforeinstallprompt, que cerró también el de Web Install). Su argumento va más por el modelo que por lo técnico: para ellos, instalar es una acción que tiene que iniciar el usuario desde el navegador. En sus palabras, lo que importa es "who determines when the installation flow begins": quién decide cuándo arranca el flujo de instalación. Que el diálogo sea del navegador no cambia que lo dispare la página. Y agregan que instalar una web app le da "poderes especiales" (en la práctica, en iOS: más almacenamiento, badges, la posibilidad de pedir notificaciones push), lo que le da a los sitios un incentivo para insistir. Su alternativa es que el sitio pueda detectar sin prompt si está corriendo como app instalada (un valor nuevo de display-mode), y dejar la instalación en manos del navegador.
En el mismo issue aparecen datos de Chrome, compartidos por Dan Murphy: el ícono de instalación en la barra de direcciones tiene un CTR de alrededor del 0,01%, y los prompts del navegador rondan el 1,2%. Su conclusión es que el navegador es mal juez de cuándo ofrecer la instalación, y que el sitio sabe más. La lectura de WebKit es otra: quizás la mayoría de la gente simplemente no quiere instalar, y darle al sitio el botón solo aumenta la insistencia. Esos números hay que leerlos con cuidado, porque son telemetría interna citada en una discusión y no un estudio publicado.
Mozilla no tiene posición todavía, pero alguien de su equipo dejó un comentario en otro issue: critica beforeinstallprompt porque prompt() se puede llamar en cualquier click, incluso desde un iframe, y dice que el elemento <install> no tiene ese problema porque solo cuenta un click sobre el botón del navegador. No es una posición oficial, pero sugiere que el elemento tiene más chances que la API.
El TAG del W3C declinó la propuesta y aclaró que su posición no cambió respecto de revisiones anteriores. Está conforme con la instalación same-origin, pero quiere verla desplegada antes de hablar de cross-origin. Y objeta algo que me parece razonable: usar nombres tan genéricos como navigator.install() y <install> para una API que trae incorporado el modelo de instalación de un solo motor, cuando otros navegadores instalan sin manifest ni id. Va a escribir un documento propio sobre instalación de web apps.
Conclusión
Yo lo trataría como una mejora progresiva solo para Chromium desktop, en la línea de Web Bluetooth. Que no vaya a estar en Safari no es motivo para ignorarlo, porque el fallback es trivial.
Lo que haría hoy:
- Agregar
idystart_urlexplícitos al manifest y revisar que se sirva sin redirects y conContent-TypeJSON. Es gratis. - Reemplazar el banner casero basado en
beforeinstallpromptpor un<install>con el link actual adentro como fallback. - Si tenés varias apps, armar la página de "nuestras apps" con un
<install manifest>por cada una, y no con la API, para evitarle al usuario el diálogo de permiso extra. - No usar
successpara analytics de instalaciones nuevas.
Lo que voy a seguir de cerca es qué hace Mozilla con el elemento. Si lo adopta, deja de ser una pelea entre Chrome y Safari, y lo que se discute pasa a ser si un botón del navegador dentro de la página es una buena forma de pedirle cosas al usuario. Con los capability elements y ahora con esto, Chrome ya está apostando a que sí.

