Blog · Guías
Notificaciones push en una app Next.js, paso a paso
Service worker en public, un componente de cliente que suscribe desde un clic y una server action que envía con la clave secreta: web push en Next.js con App Router.
Para añadir notificaciones push a una app Next.js con App Router necesitas cuatro piezas: un service worker en public/, un componente de cliente que pida permiso desde un clic, un sitio donde guardar las suscripciones y código de servidor que envíe con una clave secreta. Con castly las dos últimas se reducen a llamadas HTTP; aquí tienes cada archivo, paso a paso.
En resumen:
public/castly-sw.jscontiene una sola línea y Next.js lo sirve en/castly-sw.js.- El SDK se carga en un componente de cliente con
import(/* webpackIgnore: true */ …), que funciona con Turbopack y con webpack.Castly.subscribe({ userId })se llama directamente en elonClick, sinawaitprevios, para que el navegador muestre su diálogo de permiso.- El envío sale de una server action o de un route handler, con la clave secreta en una variable sin el prefijo
NEXT_PUBLIC_.- En iPhone, web push solo funciona con la web añadida a la pantalla de inicio;
Castly.needsInstall()te lo indica.
Qué vas a montar
| Archivo | Dónde se ejecuta | Para qué |
|---|---|---|
public/castly-sw.js | Service worker | Recibe el push y muestra la notificación |
types/castly.d.ts | Solo TypeScript | Tipos del módulo remoto del SDK |
app/push-button.tsx | Navegador | Botón que suscribe al usuario |
lib/push.ts | Servidor | Llama a la API de envío |
app/admin/orders/actions.ts | Servidor | Server action que dispara el aviso |
Y dos variables en .env.local:
NEXT_PUBLIC_CASTLY_PUBLIC_KEY=pk_live_…
CASTLY_SECRET_KEY=sk_live_… La diferencia de prefijo importa. Next.js solo incluye en el bundle del navegador las variables que empiezan por NEXT_PUBLIC_; el resto se sustituye por una cadena vacía en el cliente. La clave pública está pensada para el navegador (solo sirve para registrar suscriptores), mientras que la secreta permite enviar a todos tus usuarios y nunca debe llevar ese prefijo.
Paso 1: el service worker en public
Todo lo que pongas en public/ se sirve desde la raíz del dominio. Crea public/castly-sw.js con esta línea:
importScripts('https://castly.sh/sdk/v1/sw.js'); Al estar en la raíz, su ámbito cubre todas las rutas de la app. Si ya tienes un service worker propio (por ejemplo, porque tu app es una PWA con caché offline), no registres un segundo con el mismo ámbito: añade esa línea al tuyo y pasa su ruta en Castly.init({ …, swPath: '/sw.js' }).
En desarrollo, http://localhost cuenta como contexto seguro, así que los service workers funcionan con next dev. Desde otro dispositivo de tu red con http://192.168… no, porque no es HTTPS.
Paso 2: cargar el SDK en un componente de cliente
El SDK no se instala con npm: se carga desde su URL como módulo ES. Turbopack, el bundler por defecto de Next.js, no admite imports desde URL (experimental.urlImports no está previsto para él), pero ambos bundlers respetan el comentario webpackIgnore: true en un import() dinámico: lo dejan tal cual y el navegador descarga el módulo en tiempo de ejecución.
Para que TypeScript acepte esa URL, declara el módulo en types/castly.d.ts con los métodos que vayas a usar:
declare module 'https://castly.sh/sdk/v1/castly.mjs' {
export interface CastlyClient {
init(options: { publicKey: string; autoPrompt?: boolean; swPath?: string }): unknown;
isSupported(): boolean;
needsInstall(): boolean;
permission(): NotificationPermission | 'unsupported';
subscribe(options?: { userId?: string; tags?: Record<string, string> }): Promise<{ id: string }>;
unsubscribe(): Promise<void>;
}
export const Castly: CastlyClient;
} Si prefieres no tocar imports, la alternativa es cargar https://castly.sh/sdk/v1/castly.js con next/script y usar el global window.Castly. El resto del flujo es idéntico.
Paso 3: pedir permiso desde un clic
Los navegadores solo muestran el diálogo de permiso si la petición nace de un gesto del usuario, y Safari y Firefox son estrictos con ello (lo explica MDN sobre requestPermission). Por eso el SDK se carga e inicializa al montar el componente, y en el clic solo se llama a subscribe, sin esperar nada antes:
'use client';
import { useEffect, useRef, useState } from 'react';
import type { CastlyClient } from 'https://castly.sh/sdk/v1/castly.mjs';
type State = 'loading' | 'unsupported' | 'needs-install' | 'blocked' | 'ready' | 'on';
export function PushButton({ userId }: { userId: string }) {
const castly = useRef<CastlyClient | null>(null);
const [state, setState] = useState<State>('loading');
useEffect(() => {
import(/* webpackIgnore: true */ 'https://castly.sh/sdk/v1/castly.mjs')
.then(({ Castly }) => {
Castly.init({ publicKey: process.env.NEXT_PUBLIC_CASTLY_PUBLIC_KEY!, autoPrompt: false });
castly.current = Castly;
if (!Castly.isSupported()) setState('unsupported');
else if (Castly.needsInstall()) setState('needs-install');
else if (Castly.permission() === 'denied') setState('blocked');
else setState('ready');
})
.catch(() => setState('unsupported'));
}, []);
async function enable() {
const c = castly.current;
if (!c) return;
try {
await c.subscribe({ userId }); // primera llamada del clic: el gesto sigue vigente
setState('on');
} catch {
setState(c.permission() === 'denied' ? 'blocked' : 'ready');
}
}
if (state === 'unsupported') return null;
if (state === 'needs-install') return <p>En iPhone, añade esta web a la pantalla de inicio y ábrela desde allí.</p>;
if (state === 'blocked') return <p>Bloqueaste los avisos; puedes reactivarlos en los ajustes del sitio.</p>;
if (state === 'on') return <p>Avisos activados en este dispositivo.</p>;
return (
<button onClick={enable} disabled={state === 'loading'}>
Avisarme cuando cambie mi pedido
</button>
);
} Úsalo desde cualquier página de servidor pasando el id de tu sesión: <PushButton userId={session.user.id} />. Ese userId vincula el dispositivo con tu usuario, y después puedes enviarle avisos por su id sin guardar endpoints en tu base de datos. Si además pasas etiquetas al suscribir, como tags: { plan: 'pro', idioma: 'es' }, podrás enviar a un segmento con { "type": "segment", "tags": { "plan": "pro" } } sin consultar nada en tu lado. Una misma persona puede suscribir el portátil y el teléfono: ambos quedan asociados al mismo userId y reciben el aviso.
autoPrompt: false desactiva el aviso de suscripción configurado en el panel, porque aquí el botón es tuyo. Si prefieres ese aviso (textos, posición, retraso y páginas se configuran sin código), basta con llamar a init sin esa opción. Sobre el mejor momento para pedir permiso, mira cuándo pedir permiso de notificaciones.
Paso 4: enviar desde el servidor
Centraliza la llamada en un módulo de servidor. import 'server-only' hace que el build falle si alguien lo importa desde un componente de cliente:
// lib/push.ts
import 'server-only';
type Target =
| { type: 'all' }
| { type: 'users'; userIds: string[] }
| { type: 'segment'; tags: Record<string, string> };
export async function sendPush(
message: { to: Target; title: string; body?: string; url?: string; ttl?: number },
idempotencyKey: string,
) {
const res = await fetch('https://castly.sh/v1/notifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(message),
});
if (res.status !== 202) throw new Error(`Envío rechazado (${res.status}): ${await res.text()}`);
return (await res.json()) as { id: string; status: string };
} Y úsalo donde ocurre el evento, por ejemplo en una server action del panel de pedidos:
// app/admin/orders/actions.ts
'use server';
import { sendPush } from '@/lib/push';
import { requireAdmin, markShipped } from '@/lib/orders'; // tu propio código
export async function shipOrder(orderId: string) {
await requireAdmin(); // una server action es un endpoint público: valida siempre
const order = await markShipped(orderId);
await sendPush(
{
to: { type: 'users', userIds: [order.userId] },
title: 'Tu pedido va en camino',
body: `El pedido ${order.number} sale hoy del almacén.`,
url: `https://tutienda.com/pedidos/${order.id}`,
},
`order:${order.id}:shipped`,
);
} Decide si el aviso es imprescindible para la acción. La API responde 202 en cuanto valida y encola la notificación, así que la llamada es rápida, pero un fallo de red haría que shipOrder lance un error con el pedido ya marcado como enviado. Si el aviso es secundario, envuelve la llamada en try/catch, registra el error y deja que la acción termine; podrás reenviarlo después con la misma clave sin riesgo de duplicarlo.
Si el disparador está fuera de Next.js (un cron, el webhook de tu pasarela de pagos), usa un route handler app/api/push/route.ts que exporte POST, verifique un token propio y llame a la misma función. La clave de idempotencia evita avisos duplicados si la acción se reintenta; el porqué está en idempotencia al enviar notificaciones.
Probar y depurar
- Dispositivo de prueba: en el Playground del panel de castly puedes suscribir tu propio navegador, que queda marcado como dispositivo de prueba. Enviar con
"to": { "type": "test" }te permite probar textos sin molestar a nadie. - Service worker: en las DevTools de Chrome, la pestaña Application muestra si
/castly-sw.jsestá registrado y activo, y con qué ámbito. - Registros: guarda el
idque devuelvesendPush. ConGET /v1/notifications/:idves el estado de cada dispositivo (enviado, entregado, clic) y sabes si un aviso concreto llegó o no. - Content Security Policy: si tu app define CSP en
next.configo en un middleware, añadehttps://castly.shascript-srcyconnect-src. - iPhone y iPad: necesitas HTTPS, un manifest (en App Router,
app/manifest.ts) y que el usuario abra la web desde la pantalla de inicio. Los detalles están en web push en iPhone.
Preguntas frecuentes
¿Puedo llamar a Castly.subscribe al cargar la página?
No conviene: sin un gesto del usuario, varios navegadores bloquean o ignoran la petición de permiso, y además un diálogo sin contexto suele acabar en «Bloquear». Llámalo desde un clic, cuando el usuario entienda qué va a recibir.
¿Funciona con Turbopack?
Sí. Turbopack no admite imports desde URL, pero respeta el comentario webpackIgnore: true en un import() dinámico, igual que webpack, así que el navegador carga el módulo directamente.
¿Dónde guardo la clave secreta en Next.js?
En una variable de entorno sin el prefijo NEXT_PUBLIC_, por ejemplo CASTLY_SECRET_KEY, y solo léela en código de servidor: route handlers, server actions o módulos marcados con server-only.
¿Necesito guardar las suscripciones en mi base de datos?
No. Si pasas userId al suscribir, puedes enviar a { "type": "users", "userIds": [...] } usando tus propios ids de usuario, y el servicio guarda los endpoints de cada dispositivo.
¿Qué pasa si ya tengo un service worker para la PWA?
Añade importScripts('https://castly.sh/sdk/v1/sw.js') a tu service worker y pasa su ruta en swPath al inicializar. Así no compiten dos registros por el mismo ámbito.