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.

Read in English

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.js contiene 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 el onClick, sin await previos, 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

ArchivoDónde se ejecutaPara qué
public/castly-sw.jsService workerRecibe el push y muestra la notificación
types/castly.d.tsSolo TypeScriptTipos del módulo remoto del SDK
app/push-button.tsxNavegadorBotón que suscribe al usuario
lib/push.tsServidorLlama a la API de envío
app/admin/orders/actions.tsServidorServer 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.js está registrado y activo, y con qué ámbito.
  • Registros: guarda el id que devuelve sendPush. Con GET /v1/notifications/:id ves 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.config o en un middleware, añade https://castly.sh a script-src y connect-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.

Ponlo en práctica con castlyNotificaciones push web y PWA para developers: un SDK de menos de 3 KB, una API REST y registros de entrega de cada dispositivo. Gratis hasta 10.000 notificaciones al mes.
Crear una cuenta gratis →

Envía tu primera notificación push hoy.

Crea un proyecto, añade el SDK y envía con una llamada a la API. Gratis para empezar.

Crear una cuenta gratis →