Web Push Notifications con Astro y Vercel
Web Push te permite enviar notificaciones a usuarios incluso cuando no están en tu sitio. Lo agregué a este blog para que los lectores puedan optar por recibir alertas de nuevos posts. La implementación es directa en principio — pero hay suficientes piezas móviles como para que valga la pena recorrerlas todas, incluyendo qué se rompe cuando falta una.
Cómo funciona Web Push
El flujo tiene cuatro actores:
- Navegador — se suscribe a un push service y te da su endpoint
- Push service — el servidor del vendor del navegador (FCM para Chrome, Mozilla para Firefox) que entrega el mensaje
- Tu servidor — envía el mensaje al push service
- Service worker — recibe el evento push y muestra la notificación
Nunca hablás directamente con el navegador del usuario. Enviás un mensaje al push service, que lo entrega al navegador, que despierta al service worker.
VAPID keys
VAPID (Voluntary Application Server Identification) es cómo probás al push service que sos quien creó la suscripción. Generás un par de claves pública/privada una vez y mantenés la privada secreta.
bunx web-push generate-vapid-keys
Esto te da dos valores:
VAPID_PUBLIC_KEY— compartida con el navegador al suscribirseVAPID_PRIVATE_KEY— usada del lado del servidor para firmar mensajes, nunca expuesta
Configuración del entorno
En astro.config.mjs, declarar las env vars de VAPID con el sistema de env tipado de Astro:
VAPID_PUBLIC_KEY: envField.string({ context: 'client', access: 'public', optional: true }),
VAPID_PRIVATE_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
VAPID_SUBJECT: envField.string({ context: 'server', access: 'public', optional: true }),
VAPID_PUBLIC_KEY es context: 'client' porque el navegador la necesita para crear la suscripción. VAPID_PRIVATE_KEY es context: 'server' — nunca sale del servidor.
Agregar ambas a las variables de entorno de Vercel. Sin ellas, el endpoint de la key VAPID devuelve 503 y las suscripciones fallan silenciosamente.
El flujo de suscripción
Paso 1: Servir la clave VAPID pública
// src/pages/api/push/vapid-key.json.ts
export const GET: APIRoute = async () => {
if (!VAPID_PUBLIC_KEY) {
return new Response(JSON.stringify({ error: 'Not configured' }), { status: 503 })
}
return new Response(JSON.stringify({ vapidKey: VAPID_PUBLIC_KEY }))
}
Paso 2: Suscripción del lado del cliente
const res = await fetch('/api/push/vapid-key.json')
const { vapidKey } = await res.json()
const reg = await navigator.serviceWorker.ready
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true, // obligatorio en Chrome
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
await fetch('/api/push/subscribe.json', {
method: 'POST',
body: JSON.stringify({ endpoint: subscription.endpoint, keys: subscription.toJSON().keys }),
})
Paso 3: Guardar la suscripción en la base de datos
await db.insert(pushSubscriptions).values({
endpoint, p256dh: keys.p256dh, auth: keys.auth,
lang, userId: locals.user?.id ?? null,
})
Paso 4: Service worker recibe eventos push
self.addEventListener('push', event => {
const data = event.data.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/favicon-96x96.png',
data: { url: data.url },
})
)
})
self.addEventListener('notificationclick', event => {
event.notification.close()
if (event.notification.data?.url) {
event.waitUntil(clients.openWindow(event.notification.data.url))
}
})
Paso 5: Enviar un broadcast desde el servidor
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
for (const sub of activeSubscriptions) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title, body, url }),
)
} catch (err: any) {
// 410 Gone = suscripción expirada, marcar inactiva
if (err.statusCode === 410) {
await db.update(pushSubscriptions)
.set({ isActive: false })
.where(eq(pushSubscriptions.endpoint, sub.endpoint))
}
}
}
Qué sale mal
VAPID keys faltantes → 503
Si VAPID_PUBLIC_KEY no está en Vercel, el endpoint devuelve 503. La suscripción falla silenciosamente.
Debug: abrir DevTools Network, hacer click en el toggle de notificaciones, ver si la request de la VAPID key devuelve 503.
userVisibleOnly: true es obligatorio
Chrome requiere userVisibleOnly: true en el subscribe(). Sin ello, la suscripción falla con DOMException.
410 Gone significa suscripción muerta
Cuando el push service devuelve 410, el usuario revocó el permiso o la suscripción expiró. Marcar isActive: false inmediatamente — reintentar una suscripción 410 desperdicia recursos.
Safari en iOS requiere un gesto del usuario
En iOS, Notification.requestPermission() debe llamarse dentro de un handler de interacción directa del usuario. Llamarlo en page load o después de un delay asíncrono falla silenciosamente.
Resumen
| Qué | Dónde |
|---|---|
| VAPID keys | Variables de entorno de Vercel |
| Endpoint clave pública | /api/push/vapid-key.json |
| Endpoint suscripción | /api/push/subscribe.json |
| Storage | Postgres via Drizzle |
| Handler de eventos push | public/service-worker.js |
| Endpoint broadcast | /api/push/broadcast.json |
| UI admin | /admin/notifications |
El principal modo de falla siempre es el mismo: una variable de entorno faltante o un problema de registro del service worker. Revisar esas dos primero antes de ir a buscar el problema en otro lugar.




Comments for wbpshn