Volver al Blog
Desarrollo27 de octubre de 202611 min de lectura

Deep links que funcionan: el usuario que pulsa un enlace y no tiene la app

Un enlace de un email abre la web en vez de la app, o abre la app en la pantalla de inicio y pierde el contexto. Cómo funcionan los universal links y los app links, por qué fallan en silencio y cómo no perder al usuario que todavía no tiene la app instalada.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Deep links que funcionan: el usuario que pulsa un enlace y no tiene la app

Un usuario recibe un email con un enlace a un pedido, lo pulsa, se abre la app y ve el pedido. El desarrollador lo prueba en su móvil y funciona. QA lo prueba y mismo resultado. Entonces sale a producción y empiezan los errores; el enlace abre la web, o abre la app pero en la pantalla de inicio en vez del detalle del pedido, o en el peor de los casos, el usuario no tenía instalada la app, así que lleva a la tienda, luego la abre y aterriza en un onboarding que no sabe nada del enlace pulsado.

Los deep links son uno de esos casos en los que las funcionalidades en las que cada pieza está documentada, pero el conjunto no. Hay un fichero que vive en el servidor web, una configuración en el proyecto nativo, una verificación que hace el SO algunas veces (y otras no) y un router en la app que tiene que estar listo antes de que llegue la URL del enlace. Si cualquiera de esas piezas falla, no hay error visible. Simplemente, se abre el navegador.

1. Tres tipos de enlace

Es importante aclarar antes de nada, que hay tres cosas que suelen confundirse bajo el nombre de "deep link":

  • Custom schemes (miapp://pedido/123). Es el mecanismo más antiguo. La app registra un "esquema" y el sistema le pasa cualquier URL que empiece por él. Funciona siempre que la app esté instalada. En caso contrario, el navegador muestra un error o no hace nada. Tiene el problema de que cualquier otra app puede registrar el mismo esquema, por lo que entrarían en conflicto.
  • Universal links (iOS) y App links (Android). URLs https normales (como https://miapp.com/pedido/123). Si la app está instalada y el dominio está verificado, el sistema abre la app. Si no, se abre la web. Este mecanismo es el recomendado a día de hoy.
  • Deferred deep links. Más que un mecanismo del sistema son un patrón. Consiste en recordar a qué enlace iba el usuario para llevarle ahí cuando instale y abra la app por primera vez (o cuando supere una pantalla de login necesaria). Ni iOS ni Android lo resuelven, así que es trabajo del desarrollador implementarlo.

Los custom schemes siguen siendo útiles para callbacks de OAuth o para comunicación entre apps propias, pero no para enlaces que vayan a ver usuarios. Los enlaces mandados por email deben funcionar en el ordenador del usuario, por lo que miapp:// no lleva a ningún sitio y por lo tanto, no funciona correctamente.

Una regla fácil para implementar una u otra opción es que cualquier enlace que salga del sistema (emails, notificaciones push, SMS, redes sociales, códigos QR) debe ser una URL https real que tenga una página web detrás. La app no sustituye a esa página, sino que la complementa.

2. El contrato entre el dominio y la app

La idea detrás de los universal links y los app links es la misma: el sistema operativo no se fía de que una app diga "yo manejo miapp.com". Necesita que el dominio lo confirme. Para eso, el dominio crea y publica un fichero en una ruta determinada que dice qué apps pueden abrir sus enlaces y cuales no.

En iOS es apple-app-site-association, y en Android es assetlinks.json. Los dos viven en /.well-known/:

1{
2  "applinks": {
3    "details": [
4      {
5        "appIDs": ["ABCDE12345.com.miapp.app"],
6        "components": [
7          { "/": "/pedido/*", "comment": "Detalle de pedido" },
8          { "/": "/invitacion/*" },
9          { "/": "/cuenta/*", "exclude": true }
10        ]
11      }
12    ]
13  }
14}

Y en el lado de la app, cada plataforma declara los dominios que quiere manejar. En iOS es el entitlement Associated Domains con applinks:miapp.com. En Android es un intent-filter con autoVerify:

XML
1<intent-filter android:autoVerify="true">
2    <action android:name="android.intent.action.VIEW" />
3    <category android:name="android.intent.category.DEFAULT" />
4    <category android:name="android.intent.category.BROWSABLE" />
5    <data android:scheme="https" android:host="miapp.com" android:pathPrefix="/pedido" />
6</intent-filter>

Hasta aquí, todo es documentación oficial. El problema es que este contrato tiene dos firmas, la del dominio y la de la app, y el sistema solo comprueba que coincidan en momentos muy concretos. Si no coinciden, no avisa a nadie, el desarrollador no se entera y el usuario lo sufre sin saber muy bien qué ocurre.

3. Por qué el enlace abre la web

Después de bastante tiempo trabajando con deep-links, algunos de los fallos más destacados son los siguientes. Ninguno produce un error visible y prácticamente todos acaban lanzando la web.

3.1 La huella del certificado no es la que el desarrollador piensa

En Android, assetlinks.json lleva el SHA-256 del certificado con el que está firmada la app. Al usar Play App Signing (lo normal para este paso), Google vuelve a firmar el APK con su propia clave antes de distribuirlo. Por lo tanto, la huella que del keystore local es la de la clave de subida, no la de la app que instala el usuario. Esto provoca que las builds de desarrollo funciona funcionen, pero las de Play Store no.

3.2 El fichero no llega como el sistema espera

Tiene que estar en HTTPS, sin redirecciones. Ni siquiera de miapp.com a www.miapp.com. También debe estar con Content-Type: application/json y accesible sin autenticación (he visto varios casos que se bloqueaba esperando un usuario y contraseña que nunca iban a llegar).

Si no se tiene esto en cuenta, un CDN que añade una redirección, un WAF que bloquea peticiones sin cookies o una regla de Next.js que reescribe /.well-known/ romperán el funcionamiento.

3.3 La verificación se realizó una vez. Falló

Android verifica los dominios al instalar la app (aunque en versiones recientes lo llega a hacer periódicamente). En cambio, iOS descarga el fichero a través de la CDN de Apple, no directamente desde el servidor de la aplicación. Luego la cachea. Al desplegar, si el fichero no estaba bien, una correción no soluciona el problema en los dispositivos que ya lo habían comprobado.

Desde Android 12, un dominio sin verificar ya no muestra el diálogo de "abrir con", abre directamente el navegador.

3.4 El enlace no viene de donde se creía

Los universal links de iOS no se activan si el usuario escribe directamente la URL en Safari o si navega desde una página del mismo dominio. Los navegadores integrados de algunas apps (redes sociales, clientes de correo) abren el enlace en su propio webview sin pasar por el sistema. Además, muchos emails envuelven el enlace en una URL de tracking de otro dominio, por lo que el enlace redirige al proveedor del email, no a la app.

El click tracking de las plataformas de email es el asesino silencioso de los universal links. El enlace que pulsa el usuario es https://click.proveedor.com/abc123, que redirige a https://miapp.com/pedido/123. Para cuando llega la redirección, ya se ha decidido abrir el navegador. La solución pasa por desactivar el tracking en esos enlaces o configurar un dominio de tracking propio que también esté asociado a la app.

3.5 Como siempre, los fabricantes

En la misma línea que con los permisos, algunas capas de fabricante tienen ajustes propios de "abrir enlaces compatibles" por app, y hay veces que vienen desactivados por defecto o se resetean tras una actualización.

Si un enlace funciona en un Pixel y no en otro móvil con la misma versión de Android, habría que revisar los ajustes del móvil antes de tocar nada de código.

4. Diagnosticar antes de tocar código

Lo peor de los deep links es depurarlos a ciegas. Por suerte, tanto Android como iOS tienen herramientas para saber directamente qué opina el sistema del dominio de la app.

En Android, adb muestra el estado de verificación de cada dominio y permite forzar una nueva verificación. Los comandos son:

Bash
1# Estado de verificación de los dominios de la app
2adb shell pm get-app-links com.miapp.app
3
4# Forzar una nueva verificación (Android 12+)
5adb shell pm verify-app-links --re-verify com.miapp.app
6
7# Lanzar un enlace como si lo pulsara el usuario
8adb shell am start -a android.intent.action.VIEW -d "https://miapp.com/pedido/123"

Si el estado no es verified, el problema está en el contrato (puede ser el fichero, la huella o el intent-filter). Si es verified y aun así se abre el navegador, el problema está en cómo llega el enlace (redirecciones, apps intermedias, ajustes del fabricante).

En iOS no hay un equivalente tan directo. Es posible comprobar qué versión del fichero tiene la CDN de Apple pidiéndolo directamente a https://app-site-association.cdn-apple.com/a/v1/miapp.com. Si lo que devuelve no coincide con lo que hay en el servidor de la aplicación, iOS está trabajando con la versión antigua (o si se te olvida varias veces, con alguna versión todavía más vieja). Para desarrollo, añadir ?mode=developer al dominio en el entitlement hace que el dispositivo lo descargue directamente de tu servidor, sin pasar por la CDN.

Merece la pena añadir un test en el pipeline de despliegue que compruebe que los enlaces devuelven un 200 sin redirecciones con el Content-Type correcto y que la huella coincide con la de producción. Es un fichero que no se va a tocar mucho.

5. La app tiene que estar lista para recibir la URL

En React Native, una URL puede llegar por dos caminos, dependiendo del estado de la app:

  • Arranque en frío. La app no estaba abierta. La URL se obtiene con Linking.getInitialURL().
  • Arranque en caliente. La app estaba en segundo plano (es muy raro que esté en primer plano y reciba una petición de este estilo). La URL llega como evento a través de Linking.addEventListener('url', ...).

Los fallos típicos aquí son tres:

  1. Navegar antes de que el navegador esté montado, con lo que la llamada se pierde.
  2. Procesar la misma URL dos veces (una por getInitialURL y otra por el evento). Puede provocar que se naveguen dos veces a la misma pantalla
  3. Navegar a una pantalla protegida antes de saber si el usuario tiene la sesión iniciada, lo que le manda al login y descarta el destino.

Una forma robusta de solucionarlo es guardar la URL como "destino pendiente" y consumirla cuando sea necesario (después del Login, al comprobar que solo hay una navegación pendiente...).

1import { Linking } from "react-native"
2
3type PendingLink = { url: string; receivedAt: number }
4
5let pending: PendingLink | null = null
6let lastHandledUrl: string | null = null
7const listeners = new Set<() => void>()
8
9function setPending(url: string | null) {
10  // Ignora la misma URL si llega por los dos caminos a la vez.
11  if (!url || url === lastHandledUrl) return
12  pending = { url, receivedAt: Date.now() }
13  listeners.forEach((notify) => notify())
14}
15
16export function startDeepLinkCapture() {
17  Linking.getInitialURL().then(setPending)
18  const subscription = Linking.addEventListener("url", ({ url }) => setPending(url))
19  return () => subscription.remove()
20}
21
22export function consumePendingLink(): string | null {
23  if (!pending) return null
24  const { url } = pending
25  lastHandledUrl = url
26  pending = null
27  return url
28}
29
30export function onPendingLink(listener: () => void) {
31  listeners.add(listener)
32  return () => listeners.delete(listener)
33}

Y la navegación consume el destino solo cuando se cumplen las condiciones. En el caso de la sesión, cuando ya ha comprobado que ha hecho el Login y puede entrar.

TypeScript
1function useDeepLinkNavigation(navigationReady: boolean, auth: AuthState) {
2  useEffect(() => {
3    const tryConsume = () => {
4      if (!navigationReady || auth.status === "loading") return
5      if (auth.status === "signedOut") return // se queda pendiente hasta el login
6
7      const url = consumePendingLink()
8      if (!url) return
9
10      const route = resolveRoute(url)
11      navigationRef.navigate(route.name, route.params)
12    }
13
14    tryConsume()
15    return onPendingLink(tryConsume)
16  }, [navigationReady, auth.status])
17}

Con este modelo, el caso del login deja de ser un problema. El usuario pulsa el enlace, la app ve que no hay sesión, muestra el login, y en cuanto auth.status pasa a signedIn, el efecto se vuelve a ejecutar y consume el destino que seguía pendiente.

Un deep link no es una orden de navegación. Es una intención del usuario (muchas veces los conceptos van de la mano con los Intents) que hay que guardar hasta que la app esté en condiciones de cumplirla.

6. Qué pasa cuando el usuario no tiene la app

Queda el caso más difícil: el usuario pulsa el enlace sin tener la app instalada.

El sistema abre la web, la web le sugiere instalar la app, el usuario va a la tienda, instala y abre. Pero al abrir la app, esta ya no sabe nada del enlace, y por lo tanto no efectúa ninguna acción. Es lo que se conoce como deferred deep linking. Ninguna de las dos plataformas lo ofrece de serie.

Durante años la respuesta fue Firebase Dynamic Links, pero Google lo cerró en agosto de 2025. Las alternativas son servicios de terceros (Branch, AppsFlyer, Adjust) o montarlo uno mismo, y aquí las dos plataformas son muy distintas:

  • Android tiene una solución oficial y fiable: la Install Referrer API de Google Play. Si el enlace a la tienda lleva un parámetro referrer, la app puede leerlo en su primer arranque. Basta con que la web, al redirigir a la Play Store, añada el destino original a ese parámetro.
  • iOS no tiene equivalente. La App Store no pasa ningún parámetro a la app instalada. Algunas opciones son utilizar el portapapeles, pero se muestra un mensaje al usuario para pegar el contenido, o el fingerprinting probabilístico (IP, modelo, idioma...), que es poco fiable y choca de frente con las políticas de privacidad de Apple (y te lo pueden echar para atrás).

En la práctica, para iOS suele funcionar mejor rediseñar el flujo para que no dependa de ello. Si el enlace es una invitación, la web puede pedir el email antes de mandar a la tienda, y la app recupera la invitación por cuenta tras el registro. Si es un pedido, el usuario lo verá al iniciar sesión de todos modos. De esta forma, la app puede sobrevivir sin estos avisos de URLs.

El deferred deep link pasa de ser un requisito a ser una mejora que solo funciona bien en Android.

TypeScript
1// Primer arranque en Android: recuperar el destino desde el Install Referrer.
2import { getInstallReferrer } from "./installReferrer" // wrapper del módulo nativo
3
4export async function recoverDeferredLink() {
5  if (await storage.get("deferredLinkChecked")) return
6  await storage.set("deferredLinkChecked", true)
7
8  const referrer = await getInstallReferrer() // "utm_source=web&target=%2Fpedido%2F123"
9  const target = new URLSearchParams(referrer ?? "").get("target")
10  if (target) setPending(`https://miapp.com${target}`)
11}

El destino recuperado de un referrer (o de cualquier otra fuente externa) es necesario validarlo.

7. Qué montar desde el principio

Si empezara hoy una app con enlaces externos, hay cuatro cosas que dejaría resueltas antes de que llegue la primera captura de un usuario:

  • Un único dominio para todos los enlaces, con su página web real detrás de cada ruta. Ni miapp:// en comunicaciones a usuarios, ni dominios de tracking ajenos delante.
  • Los ficheros de /.well-known/ verificados en el pipeline, con la huella de producción de Play Console y no la del keystore local.
  • Un "destino pendiente" en vez de navegación directa, consumido por una única pieza que conoce el estado del navegador y de la sesión.
  • Una lista blanca de rutas que traduzca URLs a pantallas, con validación de parámetros, para que ningún enlace pueda llevar a un sitio que el usuario no podría alcanzar navegando.

Ninguna de estas piezas es complicada por separado. El problema de los deep links nunca ha sido la dificultad técnica de cada parte, sino que son cinco sistemas distintos (servidor web, CDN, sistema operativo, tienda y app) que tienen que estar de acuerdo y convivir, y ninguno avisa cuando deja de estarlo.

Artículos Relacionados

Ver todos los artículos
Los permisos de Android cambian según la marca del móvil

Los permisos de Android cambian según la marca del móvil

Xiaomi, Samsung, Huawei... cada fabricante tiene su propio infierno de permisos. Por qué acabas escribiendo un gestor propio y cómo domar las peculiaridades por marca.