Advanced (Actualizado: 2/6/2026)

Sistema de notificaciones en produccion con Claude Code

Crea notificaciones con Claude Code: DB, API, React, no leidos, idempotencia, reintentos y pruebas.

Sistema de notificaciones en produccion con Claude Code

Un sistema de notificaciones no es solo un icono de campana. Los fallos reales aparecen con envios duplicados, contadores de no leidos incorrectos, demasiados emails, reintentos perdidos y datos privados en mensajes externos. Esta guia muestra como pedirle a Claude Code una base de produccion: modelo de datos, API, servicio, centro de notificaciones en React y pruebas.

La frontera importante es simple: la notificacion dentro de la app es la fuente de verdad. Email y push son canales externos que se alimentan desde una cola. Primero se guarda el registro, despues se decide si se envia fuera segun preferencias, severidad, lotes, limite de frecuencia y privacidad.

Para contrastar detalles usa la documentacion oficial: MDN Notifications API, Next.js Route Handlers y PostgreSQL CREATE TABLE. Si conectas email, revisa una guia oficial como Resend con Next.js, pero conecta el proveedor desde un worker de cola, no desde el request. La base de Claude Code esta en la documentacion oficial.

Limites del sistema

Divide el sistema en cuatro capas.

CapaResponsabilidadFallo habitual
EventoPago fallido, comentario, job terminadoEl mismo webhook llega dos veces
ServicioGuardar, deduplicar, no leidos, preferenciasReglas repartidas en el UI
ColaReintentos de email y pushUn fallo externo pierde el aviso
UICentro, badge y alerta del navegadorConteo distinto entre pestanas y dispositivos

No empieces por push. Las notificaciones del navegador requieren permiso y contexto seguro; en movil muchas veces necesitas Service Worker. Email tiene costes, bajas, quejas y entregabilidad. Por eso conviene guardar primero la notificacion interna y tratar email/push como efectos secundarios controlados.

Prompt recomendado para Claude Code:

Build a notification system for Next.js App Router and PostgreSQL.
In-app notifications are the source of truth. Email and push only go into a delivery queue.
Requirements: unread state, idempotency_key, batching, user preferences, rate limit, retry queue, privacy-safe payload.
Implement DB schema, service, route handler, React notification center, and Vitest tests.

Idempotencia significa que repetir la misma operacion no crea resultados extra. En notificaciones, el mismo webhook de pago debe crear una sola notificacion. Batching significa agrupar eventos ruidosos, por ejemplo diez comentarios en un resumen. Retry queue es una cola durable que permite reintentar envios externos tras un fallo.

Casos de uso

Primer caso: alertas operativas de un SaaS. Fallos de cobro, sincronizacion o jobs pueden necesitar aviso interno y email. Si un job falla cien veces en un minuto, no envies cien correos. Usa entrega inmediata para critical y resumen para el resto.

Segundo caso: colaboracion de equipo. Menciones, comentarios y solicitudes de revision necesitan estado de no leido fiable. El usuario espera marcar una notificacion o todas como leidas. En email o push no incluyas el comentario completo; usa un resumen y una URL autenticada.

Tercer caso: operaciones de contenido e ingresos. En ClaudeCodeLab, una notificacion puede avisar de checklist de publicacion, CTA roto, entrega de PDF gratuito, onboarding tras compra o recordatorio de consulta. Las notificaciones deben ayudar a la conversion sin crear ruido. Puedes conectar este tema con tips de productividad de Claude Code y validacion de formularios con Claude Code.

Cuarto caso: auditoria administrativa. Cambios de permisos, creacion de API keys y exportaciones de datos deben poder revisarse despues. El cuerpo debe evitar datos sensibles y apuntar a un registro autenticado.

Esquema de base de datos

Este schema de PostgreSQL separa la fuente interna, la cola, las preferencias y el limite de frecuencia.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE notification_preferences (
  user_id text PRIMARY KEY,
  in_app_enabled boolean NOT NULL DEFAULT true,
  email_enabled boolean NOT NULL DEFAULT false,
  push_enabled boolean NOT NULL DEFAULT false,
  digest_minutes integer NOT NULL DEFAULT 5,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE notifications (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id text NOT NULL,
  event_name text NOT NULL,
  title text NOT NULL,
  body text NOT NULL,
  target_url text,
  severity text NOT NULL DEFAULT 'info',
  data jsonb NOT NULL DEFAULT '{}'::jsonb,
  idempotency_key text,
  batch_key text,
  read_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX notifications_user_idempotency_unique
  ON notifications (user_id, idempotency_key)
  WHERE idempotency_key IS NOT NULL;

CREATE INDEX notifications_user_created_idx
  ON notifications (user_id, created_at DESC);

CREATE INDEX notifications_user_unread_idx
  ON notifications (user_id, created_at DESC)
  WHERE read_at IS NULL;

CREATE TABLE notification_delivery_queue (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  notification_id uuid NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
  channel text NOT NULL CHECK (channel IN ('email', 'push')),
  status text NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
  attempts integer NOT NULL DEFAULT 0,
  max_attempts integer NOT NULL DEFAULT 5,
  available_at timestamptz NOT NULL DEFAULT now(),
  locked_at timestamptz,
  last_error text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX notification_delivery_queue_pick_idx
  ON notification_delivery_queue (status, available_at, created_at);

CREATE TABLE notification_rate_limits (
  user_id text NOT NULL,
  channel text NOT NULL,
  window_start timestamptz NOT NULL,
  count integer NOT NULL DEFAULT 0,
  PRIMARY KEY (user_id, channel, window_start)
);

Usa read_at como unica verdad del estado leido. Si tambien guardas is_read, tarde o temprano ambos campos se contradicen. Para borrado, suele ser mejor archived_at que eliminar fisicamente.

Servicio de notificaciones

Instala dependencias:

npm install pg
npm install -D @types/pg vitest

El archivo lib/notification-service.ts guarda, deduplica, aplica preferencias, limita canales externos y maneja la cola.

import { Pool, type PoolClient } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export type NotificationSeverity = "info" | "success" | "warning" | "critical";
export type ExternalChannel = "email" | "push";

export type NotificationRow = {
  id: string;
  user_id: string;
  event_name: string;
  title: string;
  body: string;
  target_url: string | null;
  severity: NotificationSeverity;
  data: Record<string, unknown>;
  idempotency_key: string | null;
  batch_key: string | null;
  read_at: string | null;
  created_at: string;
};

type Preferences = {
  in_app_enabled: boolean;
  email_enabled: boolean;
  push_enabled: boolean;
  digest_minutes: number;
};

export type CreateNotificationInput = {
  userId: string;
  eventName: string;
  title: string;
  body: string;
  targetUrl?: string;
  severity?: NotificationSeverity;
  data?: Record<string, unknown>;
  resourceId?: string;
  idempotencyKey?: string;
  batchKey?: string;
};

export function makeIdempotencyKey(input: {
  userId: string;
  eventName: string;
  resourceId?: string;
}) {
  if (!input.resourceId) return null;
  return `${input.userId}:${input.eventName}:${input.resourceId}`;
}

export function shouldQueueExternal(input: {
  channel: ExternalChannel;
  severity: NotificationSeverity;
  preferences: Pick<Preferences, "email_enabled" | "push_enabled">;
}) {
  if (input.channel === "email" && !input.preferences.email_enabled) return false;
  if (input.channel === "push" && !input.preferences.push_enabled) return false;
  return input.severity === "warning" || input.severity === "critical";
}

async function ensurePreferences(client: PoolClient, userId: string) {
  const result = await client.query<Preferences>(
    `
    INSERT INTO notification_preferences (user_id)
    VALUES ($1)
    ON CONFLICT (user_id) DO UPDATE
      SET updated_at = notification_preferences.updated_at
    RETURNING in_app_enabled, email_enabled, push_enabled, digest_minutes
    `,
    [userId],
  );
  return result.rows[0];
}

async function consumeRateLimit(
  client: PoolClient,
  userId: string,
  channel: ExternalChannel,
  limitPerMinute: number,
) {
  const result = await client.query<{ count: number }>(
    `
    INSERT INTO notification_rate_limits (user_id, channel, window_start, count)
    VALUES ($1, $2, date_trunc('minute', now()), 1)
    ON CONFLICT (user_id, channel, window_start)
    DO UPDATE SET count = notification_rate_limits.count + 1
    RETURNING count
    `,
    [userId, channel],
  );
  return result.rows[0].count <= limitPerMinute;
}

async function queueDelivery(
  client: PoolClient,
  notificationId: string,
  channel: ExternalChannel,
  availableAt: Date,
) {
  await client.query(
    `
    INSERT INTO notification_delivery_queue (notification_id, channel, available_at)
    VALUES ($1, $2, $3)
    `,
    [notificationId, channel, availableAt],
  );
}

export async function createNotification(input: CreateNotificationInput) {
  const client = await pool.connect();
  const severity = input.severity ?? "info";
  const idempotencyKey =
    input.idempotencyKey ??
    makeIdempotencyKey({
      userId: input.userId,
      eventName: input.eventName,
      resourceId: input.resourceId,
    });

  try {
    await client.query("BEGIN");
    const preferences = await ensurePreferences(client, input.userId);
    if (!preferences.in_app_enabled) {
      await client.query("COMMIT");
      return null;
    }

    const result = await client.query<NotificationRow & { inserted: boolean }>(
      `
      WITH inserted AS (
        INSERT INTO notifications (
          user_id, event_name, title, body, target_url, severity,
          data, idempotency_key, batch_key
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
        ON CONFLICT (user_id, idempotency_key)
          WHERE idempotency_key IS NOT NULL
        DO NOTHING
        RETURNING *, true AS inserted
      )
      SELECT * FROM inserted
      UNION ALL
      SELECT *, false AS inserted
      FROM notifications
      WHERE user_id = $1
        AND idempotency_key = $8
        AND $8 IS NOT NULL
        AND NOT EXISTS (SELECT 1 FROM inserted)
      LIMIT 1
      `,
      [
        input.userId,
        input.eventName,
        input.title,
        input.body,
        input.targetUrl ?? null,
        severity,
        JSON.stringify(input.data ?? {}),
        idempotencyKey,
        input.batchKey ?? null,
      ],
    );

    const notification = result.rows[0];
    if (!notification) {
      throw new Error("Notification insert failed");
    }

    if (notification.inserted) {
      for (const channel of ["email", "push"] as const) {
        const allowedByPreference = shouldQueueExternal({
          channel,
          severity,
          preferences,
        });
        if (!allowedByPreference) continue;

        const allowedByRateLimit = await consumeRateLimit(
          client,
          input.userId,
          channel,
          channel === "email" ? 5 : 20,
        );
        if (!allowedByRateLimit) continue;

        const delayMs = input.batchKey ? preferences.digest_minutes * 60_000 : 0;
        await queueDelivery(
          client,
          notification.id,
          channel,
          new Date(Date.now() + delayMs),
        );
      }
    }

    await client.query("COMMIT");
    return notification;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

export async function listNotifications(userId: string, unreadOnly = false, limit = 30) {
  const result = await pool.query<NotificationRow>(
    `
    SELECT *
    FROM notifications
    WHERE user_id = $1
      AND ($2::boolean = false OR read_at IS NULL)
    ORDER BY created_at DESC
    LIMIT $3
    `,
    [userId, unreadOnly, Math.min(limit, 100)],
  );
  return result.rows;
}

export async function markNotificationRead(userId: string, notificationId: string) {
  const result = await pool.query<NotificationRow>(
    `
    UPDATE notifications
    SET read_at = COALESCE(read_at, now()), updated_at = now()
    WHERE user_id = $1 AND id = $2
    RETURNING *
    `,
    [userId, notificationId],
  );
  return result.rows[0] ?? null;
}

export async function markAllNotificationsRead(userId: string) {
  const result = await pool.query(
    `
    UPDATE notifications
    SET read_at = COALESCE(read_at, now()), updated_at = now()
    WHERE user_id = $1 AND read_at IS NULL
    `,
    [userId],
  );
  return result.rowCount ?? 0;
}

export type DeliveryJob = {
  id: string;
  notification_id: string;
  channel: ExternalChannel;
  attempts: number;
  max_attempts: number;
};

export async function claimNextDeliveryJob() {
  const result = await pool.query<DeliveryJob>(
    `
    UPDATE notification_delivery_queue
    SET status = 'sending', attempts = attempts + 1, locked_at = now(), updated_at = now()
    WHERE id = (
      SELECT id
      FROM notification_delivery_queue
      WHERE status = 'pending' AND available_at <= now()
      ORDER BY available_at ASC, created_at ASC
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING id, notification_id, channel, attempts, max_attempts
    `,
  );
  return result.rows[0] ?? null;
}

export async function finishDeliveryJob(job: DeliveryJob, error?: unknown) {
  if (!error) {
    await pool.query(
      "UPDATE notification_delivery_queue SET status = 'sent', updated_at = now() WHERE id = $1",
      [job.id],
    );
    return;
  }

  const retryDelayMs = Math.min(30 * 60_000, 60_000 * 2 ** job.attempts);
  const failedPermanently = job.attempts >= job.max_attempts;

  await pool.query(
    `
    UPDATE notification_delivery_queue
    SET status = $2,
        available_at = $3,
        last_error = $4,
        updated_at = now()
    WHERE id = $1
    `,
    [
      job.id,
      failedPermanently ? "failed" : "pending",
      new Date(Date.now() + retryDelayMs),
      error instanceof Error ? error.message : String(error),
    ],
  );
}

Lo esencial es que un evento duplicado devuelve la notificacion existente y no crea nuevos trabajos externos.

API Route

Crea app/api/notifications/route.ts. El ejemplo usa x-demo-user-id para poder probarlo; en produccion debes reemplazarlo por sesion real.

import { NextRequest, NextResponse } from "next/server";
import {
  createNotification,
  listNotifications,
  markAllNotificationsRead,
  markNotificationRead,
} from "@/lib/notification-service";

export const runtime = "nodejs";

function requireDemoUserId(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) {
    throw new Error("Missing x-demo-user-id header. Replace this with real auth.");
  }
  return userId;
}

export async function GET(request: NextRequest) {
  try {
    const userId = requireDemoUserId(request);
    const unreadOnly = request.nextUrl.searchParams.get("unread") === "1";
    const notifications = await listNotifications(userId, unreadOnly);
    return NextResponse.json({ notifications });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Unknown error" },
      { status: 401 },
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const userId = requireDemoUserId(request);
    const body = await request.json();
    const notification = await createNotification({
      userId,
      eventName: String(body.eventName),
      title: String(body.title),
      body: String(body.body),
      targetUrl: body.targetUrl ? String(body.targetUrl) : undefined,
      severity: body.severity,
      resourceId: body.resourceId ? String(body.resourceId) : undefined,
      batchKey: body.batchKey ? String(body.batchKey) : undefined,
      data: typeof body.data === "object" && body.data ? body.data : {},
    });

    return NextResponse.json({ notification }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Unknown error" },
      { status: 400 },
    );
  }
}

export async function PATCH(request: NextRequest) {
  const userId = requireDemoUserId(request);
  const body = await request.json().catch(() => ({}));

  if (body.notificationId) {
    const notification = await markNotificationRead(userId, String(body.notificationId));
    return NextResponse.json({ notification });
  }

  const updated = await markAllNotificationsRead(userId);
  return NextResponse.json({ updated });
}

No confies en userId enviado por el body. Las notificaciones contienen estado privado y cada update debe filtrar por propietario.

Centro de notificaciones React

El UI mantiene el centro interno como principal. La alerta del navegador solo se activa si el usuario da permiso con un click.

"use client";

import { useEffect, useMemo, useState } from "react";

type AppNotification = {
  id: string;
  title: string;
  body: string;
  target_url: string | null;
  severity: "info" | "success" | "warning" | "critical";
  read_at: string | null;
  created_at: string;
};

const demoHeaders = {
  "x-demo-user-id": "demo-user-1",
};

export function NotificationCenter() {
  const [items, setItems] = useState<AppNotification[]>([]);
  const [open, setOpen] = useState(false);
  const [desktopEnabled, setDesktopEnabled] = useState(false);

  const unreadCount = useMemo(
    () => items.filter((item) => item.read_at === null).length,
    [items],
  );

  async function loadNotifications() {
    const response = await fetch("/api/notifications", {
      headers: demoHeaders,
      cache: "no-store",
    });
    if (!response.ok) return;
    const data = (await response.json()) as { notifications: AppNotification[] };
    setItems(data.notifications);
  }

  async function markRead(notificationId: string) {
    await fetch("/api/notifications", {
      method: "PATCH",
      headers: {
        ...demoHeaders,
        "content-type": "application/json",
      },
      body: JSON.stringify({ notificationId }),
    });
    setItems((current) =>
      current.map((item) =>
        item.id === notificationId
          ? { ...item, read_at: item.read_at ?? new Date().toISOString() }
          : item,
      ),
    );
  }

  async function markAllRead() {
    await fetch("/api/notifications", {
      method: "PATCH",
      headers: {
        ...demoHeaders,
        "content-type": "application/json",
      },
      body: JSON.stringify({ all: true }),
    });
    const now = new Date().toISOString();
    setItems((current) => current.map((item) => ({ ...item, read_at: item.read_at ?? now })));
  }

  async function enableDesktopNotifications() {
    if (!("Notification" in window)) return;
    const permission = await Notification.requestPermission();
    setDesktopEnabled(permission === "granted");
  }

  useEffect(() => {
    loadNotifications();
    const timer = window.setInterval(loadNotifications, 30_000);
    return () => window.clearInterval(timer);
  }, []);

  useEffect(() => {
    if (!desktopEnabled || document.visibilityState === "visible") return;
    const newest = items.find((item) => item.read_at === null);
    if (!newest) return;
    new Notification(newest.title, {
      body: newest.body,
      tag: newest.id,
    });
  }, [desktopEnabled, items]);

  return (
    <div className="notification-center">
      <button type="button" onClick={() => setOpen((value) => !value)}>
        Notifications
        {unreadCount > 0 ? <span aria-label={`${unreadCount} unread`}>{unreadCount}</span> : null}
      </button>

      {open ? (
        <section aria-label="Notification center">
          <div>
            <button type="button" onClick={markAllRead} disabled={unreadCount === 0}>
              Mark all read
            </button>
            <button type="button" onClick={enableDesktopNotifications}>
              Enable desktop alerts
            </button>
          </div>

          {items.length === 0 ? (
            <p>No notifications.</p>
          ) : (
            <ul>
              {items.map((item) => (
                <li key={item.id} data-unread={item.read_at === null}>
                  <strong>{item.title}</strong>
                  <p>{item.body}</p>
                  <small>{new Date(item.created_at).toLocaleString()}</small>
                  <div>
                    {item.target_url ? <a href={item.target_url}>Open</a> : null}
                    {item.read_at === null ? (
                      <button type="button" onClick={() => markRead(item.id)}>
                        Mark read
                      </button>
                    ) : null}
                  </div>
                </li>
              ))}
            </ul>
          )}
        </section>
      ) : null}
    </div>
  );
}

Polling cada 30 segundos es suficiente para la primera version. WebSocket o SSE pueden esperar; lo primero es que el estado leido sea correcto.

Pruebas

notification-service.test.ts fija las reglas que evitan incidentes.

import { describe, expect, it } from "vitest";
import { makeIdempotencyKey, shouldQueueExternal } from "./notification-service";

describe("notification policy", () => {
  it("builds a stable idempotency key from user, event, and resource", () => {
    expect(
      makeIdempotencyKey({
        userId: "user_1",
        eventName: "invoice.payment_failed",
        resourceId: "invoice_9",
      }),
    ).toBe("user_1:invoice.payment_failed:invoice_9");
  });

  it("does not build an idempotency key without a resource id", () => {
    expect(
      makeIdempotencyKey({
        userId: "user_1",
        eventName: "system.notice",
      }),
    ).toBeNull();
  });

  it("queues email only when preferences and severity allow it", () => {
    expect(
      shouldQueueExternal({
        channel: "email",
        severity: "warning",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(true);

    expect(
      shouldQueueExternal({
        channel: "email",
        severity: "info",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(false);

    expect(
      shouldQueueExternal({
        channel: "push",
        severity: "critical",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(false);
  });
});

Pidele a Claude Code pruebas de webhook duplicado, preferencias apagadas, exceso de limite, fallo de cola y ataque para marcar como leida una notificacion de otro usuario.

Errores comunes

El primer error es poner datos privados en el cuerpo. Email y push pueden aparecer en la pantalla bloqueada, en correos reenviados y en logs del proveedor. Usa resumen corto y enlace autenticado.

El segundo error es calcular no leidos solo en el navegador. Varias pestanas y dispositivos se desincronizan rapido. La verdad debe ser read_at IS NULL.

El tercer error es limitar la creacion del registro interno. Puedes perder auditoria. Limita la entrega externa y conserva el registro valido.

El cuarto error es dejar jobs en sending para siempre. Agrega un monitor que devuelva trabajos bloqueados antiguos a pending y muestre fallos permanentes.

CTA y resultado practico

Las notificaciones tambien apoyan ingresos: entrega de PDF gratuito, onboarding tras compra, aviso de CTA roto y recordatorios de consulta. Para plantillas mas completas revisa productos de ClaudeCodeLab.

Probe el schema y el servicio en un proyecto local de Next.js. Envie dos veces invoice.payment_failed con el mismo resourceId: notifications quedo con una fila y la cola solo tuvo el primer job. Con email desactivado, quedo el aviso interno sin trabajo de email. Definir idempotency_key y read_at antes del UI fue la decision mas importante.

Resumen

Una notificacion de produccion es primero consistencia de datos y despues interfaz. Usa la notificacion interna como fuente de verdad, manda email/push por cola y exige no leidos, idempotencia, batching, preferencias, rate limit, privacidad, reintentos y pruebas desde el primer prompt a Claude Code.

#Claude Code #notificaciones #Next.js #PostgreSQL #React
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.