Tutoriales

MQTT en Next.js: Client Components y rutas de servidor

Usa MQTT en el App Router de Next.js: datos en vivo por WebSockets seguros en un Client Component, publicación desde Route Handlers, cliente en caché y SSE.

Actualizado · 9 min de lectura

MQTT en Next.js: Client Components y rutas de servidor

MQTT en Next.js funciona mejor dividido en dos: el navegador se suscribe a datos en vivo por WebSockets seguros desde un Client Component, y todo lo que necesita secretos o escribe en los dispositivos pasa por un Route Handler que publica desde el servidor. Esta guía construye ambas mitades con MQTT.js 5 en el App Router, más una ruta opcional de Server-Sent Events para cuando el navegador no deba hablar con el broker en absoluto.

Los ejemplos usan broker.connectmq.com (WebSocket 8084 con la ruta /mqtt, TLS 8883) y topics de prueba bajo testmqtt/nextjs/.... Sustitúyelos por tu propio host y tus credenciales.

Dónde corre MQTT en una app Next.js

CódigoCorre enTransporteÚsalo para
Client Component ("use client")Navegadorwss://…:8084/mqttDashboards en vivo, notificaciones
Route Handler (app/**/route.ts)Servidor Node.jsmqtts://…:8883Comandos, escrituras, credenciales secretas
Server ComponentServidor, en cada renderEvita MQTTDatos iniciales de una base de datos o API

Los Server Components se renderizan una vez por petición (o en el build) y terminan, que es la vida útil equivocada para una suscripción. Obtén el estado inicial de tu base de datos y deja que un Client Component reciba las actualizaciones.

Instala el cliente:

Install MQTT.js
npm install mqtt server-only

Datos en vivo en un Client Component

El navegador no puede abrir un socket TCP, así que la única opción es MQTT sobre WebSockets. Crea la conexión dentro de useEffect para que nunca se ejecute durante el renderizado en el servidor, y ciérrala en la función de limpieza:

app/devices/[id]/live-telemetry.tsx
"use client";

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

const MQTT_URL =
  process.env.NEXT_PUBLIC_MQTT_URL ?? "wss://broker.connectmq.com:8084/mqtt";

type Status = "connecting" | "connected" | "reconnecting" | "offline";

export function LiveTelemetry({ deviceId }: { deviceId: string }) {
  const [status, setStatus] = useState<Status>("connecting");
  const [last, setLast] = useState<string | null>(null);

  useEffect(() => {
    const topic = `testmqtt/nextjs/${deviceId}/telemetry`;
    const client = mqtt.connect(MQTT_URL, {
      clientId: `web-${crypto.randomUUID().slice(0, 8)}`,
      username: process.env.NEXT_PUBLIC_MQTT_USER,
      password: process.env.NEXT_PUBLIC_MQTT_PASS,
      reconnectPeriod: 2000,
      connectTimeout: 10_000,
    });

    client.on("connect", () => {
      setStatus("connected");
      client.subscribe(topic, { qos: 1 });
    });
    client.on("reconnect", () => setStatus("reconnecting"));
    client.on("offline", () => setStatus("offline"));
    client.on("error", (err) => console.error("MQTT error:", err.message));
    client.on("message", (t, payload) => {
      if (t === topic) setLast(payload.toString());
    });

    return () => {
      client.end(true);
    };
  }, [deviceId]);

  return (
    <section>
      <p>MQTT: {status}</p>
      <pre>{last ?? "waiting for the first message…"}</pre>
    </section>
  );
}

Renderízalo desde una página Server Component normal; en el Next.js actual, params es una promesa:

app/devices/[id]/page.tsx
import { LiveTelemetry } from "./live-telemetry";

export default async function DevicePage({ params }: PageProps<"/devices/[id]">) {
  const { id } = await params;
  return (
    <main>
      <h1>Device {id}</h1>
      <LiveTelemetry deviceId={id} />
    </main>
  );
}

Lo que suele hacer tropezar en esta mitad:

  • Dos conexiones en desarrollo. React Strict Mode ejecuta el efecto, su limpieza y otra vez el efecto. Con client.end(true) en la limpieza, la primera conexión se cierra de inmediato. Sin ella, cada hot reload deja una conexión colgada.
  • Contenido mixto. Una página servida por HTTPS solo puede abrir sockets wss://. ws://broker...:8083 funciona en http://localhost y falla en producción con WebSocket connection to 'ws://…' failed.
  • Ruta equivocada. MQTT sobre WebSockets necesita la ruta del broker (aquí /mqtt). Si falta, normalmente termina en un HTTP 404 durante el upgrade; consulta WebSocket path 404 y WebSocket connection failed.
  • Suscribirse en connect. Se vuelve a ejecutar tras cada reconexión, que es justo lo que quieres cuando la sesión no es persistente. Suscribirse dos veces al mismo filtro no causa problemas.

Si varios componentes necesitan el mismo flujo, sube el cliente a un context provider en lugar de abrir una conexión por componente; el patrón es el mismo que en MQTT con React.

Qué puede hacer el navegador

Todo lo que lleva el prefijo NEXT_PUBLIC_ se incrusta en el bundle durante el build, así que trata esas credenciales como públicas. Dale al navegador un usuario dedicado cuya ACL permita solo suscribirse a los topics que muestra el dashboard. Publicar comandos pasa por el servidor, como verás abajo. Autenticación WebSocket en el navegador explica los tokens de corta duración si necesitas acceso por usuario.

Publicar desde un Route Handler

Los Route Handlers corren en el runtime de Node.js (el predeterminado), así que pueden usar MQTT sobre TLS en el puerto 8883. Abrir una conexión nueva por petición cuesta un handshake TCP y TLS cada vez, así que guarda en caché un cliente por proceso de servidor. Guardarlo en globalThis además sobrevive a los hot reloads de next dev:

lib/mqtt-server.ts
import "server-only";
import mqtt, { type MqttClient } from "mqtt";

const g = globalThis as unknown as { mqttClient?: Promise<MqttClient> };

export function getMqtt(): Promise<MqttClient> {
  g.mqttClient ??= mqtt
    .connectAsync("mqtts://broker.connectmq.com:8883", {
      clientId: `nextjs-${crypto.randomUUID().slice(0, 8)}`,
      username: process.env.MQTT_USER,
      password: process.env.MQTT_PASS,
      reconnectPeriod: 2000,
    })
    .catch((err) => {
      g.mqttClient = undefined; // retry on the next request
      throw err;
    });
  return g.mqttClient;
}

import "server-only" convierte una importación accidental desde un Client Component en un error de build, de modo que las credenciales de publicación nunca llegan al navegador. El client ID aleatorio importa: varias instancias de servidor que comparten un ID se expulsarían mutuamente del broker (client ID duplicado).

El handler valida la entrada, publica con QoS 1 y espera el PUBACK antes de responder:

app/api/devices/[id]/command/route.ts
import { getMqtt } from "@/lib/mqtt-server";

const ACTIONS = new Set(["reboot", "identify", "set-interval"]);

export async function POST(request: Request, ctx: RouteContext<"/api/devices/[id]/command">) {
  const { id } = await ctx.params;
  if (!/^[\w-]{1,64}$/.test(id)) {
    return Response.json({ error: "invalid device id" }, { status: 400 });
  }

  const body = await request.json().catch(() => null);
  if (!body || !ACTIONS.has(body.action)) {
    return Response.json({ error: "unknown action" }, { status: 400 });
  }

  try {
    const client = await getMqtt();
    await client.publishAsync(
      `testmqtt/nextjs/${id}/command`,
      JSON.stringify({ action: body.action, at: Date.now() }),
      { qos: 1 },
    );
    return Response.json({ ok: true });
  } catch (err) {
    console.error("publish failed", err);
    return Response.json({ error: "broker unavailable" }, { status: 503 });
  }
}

La validación del ID de dispositivo no es cosmética: un valor con /, + o # permitiría a quien llama escribir en un topic distinto del previsto. Comprueba tus filtros con el topic matcher.

Una Server Action funciona igual: marca una función en un archivo de servidor con "use server", llama a getMqtt() dentro y publica. Usa lo que mejor encaje con tu manejo de formularios; la parte MQTT es idéntica.

Serverless frente a servidores de larga duración

Con next start, en Docker o en cualquier host Node.js de larga duración, el cliente en caché sigue conectado y cada publicación es un único viaje de ida y vuelta. En plataformas serverless, una instancia de función puede congelarse entre peticiones; el socket puede estar muerto al despertar y MQTT.js se reconecta antes de enviar la publicación. Funciona, pero la primera petición tras una pausa paga la reconexión. Si los comandos son poco frecuentes, no pasa nada. Si son frecuentes, prefiere un despliegue de larga duración.

Streaming al navegador sin conexión al broker

A veces el navegador no debe hablar con el broker en absoluto: firewalls estrictos, ninguna credencial en el navegador o la necesidad de filtrar datos por usuario. Un Route Handler puede suscribirse en el servidor y reenviar los mensajes como Server-Sent Events:

app/api/devices/[id]/stream/route.ts
import mqtt from "mqtt";

export async function GET(request: Request, ctx: RouteContext<"/api/devices/[id]/stream">) {
  const { id } = await ctx.params;
  const topic = `testmqtt/nextjs/${id}/telemetry`;
  const encoder = new TextEncoder();

  const client = await mqtt.connectAsync("mqtts://broker.connectmq.com:8883", {
    username: process.env.MQTT_USER,
    password: process.env.MQTT_PASS,
  });
  await client.subscribeAsync(topic, { qos: 0 });

  const stream = new ReadableStream({
    start(controller) {
      client.on("message", (_t, payload) => {
        controller.enqueue(encoder.encode(`data: ${payload.toString()}\n\n`));
      });
      request.signal.addEventListener("abort", () => {
        client.end();
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
  });
}

En el navegador, new EventSource("/api/devices/dev-1/stream") recibe cada mensaje como un evento. Esto mantiene una conexión MQTT por pestaña abierta, así que encaja con herramientas internas y audiencias modestas en un servidor de larga duración; para audiencias grandes escala mejor el enfoque directo por WebSocket, porque el broker se encarga del fan-out.

Pruébalo de extremo a extremo

  1. Ejecuta next dev y abre /devices/dev-1. El estado debería mostrar connected.
  2. Abre el cliente MQTT online en otra pestaña, conéctate a broker.connectmq.com y publica {"temp":21.4} en testmqtt/nextjs/dev-1/telemetry. La página se actualiza al instante.
  3. Suscríbete en el cliente online a testmqtt/nextjs/+/command y llama al Route Handler:
Trigger a command
curl -X POST http://localhost:3000/api/devices/dev-1/command \
  -H "Content-Type: application/json" \
  -d '{"action":"identify"}'

El comando aparece en el cliente online y la respuesta es {"ok":true}. Prueba {"action":"format-disk"} para confirmar que el handler rechaza acciones desconocidas, y corta la red un momento para ver cómo el estado pasa a reconnecting y vuelve.

Ver en vivo los topics de prueba de Next.js

Mantén las suscripciones en Client Components sobre WebSockets seguros, los secretos y las escrituras en Route Handlers con un único cliente de servidor en caché, y recurre a SSE solo cuando el navegador deba mantenerse lejos del broker. Con esa división, el mismo código MQTT.js funciona en next dev, en CI y en producción sin más cambios que las variables de entorno.

Preguntas frecuentes

¿Puede una página de Next.js conectarse a MQTT por el puerto 1883?

Desde el navegador, no. Los navegadores no pueden abrir sockets TCP directos, así que un Client Component debe usar MQTT sobre WebSockets (wss://broker.connectmq.com:8084/mqtt). El código de servidor en los Route Handlers corre en Node.js y puede usar mqtt:// en el 1883 o mqtts:// en el 8883.

¿Por qué veo dos conexiones MQTT en desarrollo?

React Strict Mode monta, desmonta y vuelve a montar cada componente en desarrollo, así que tu useEffect se ejecuta dos veces. Si la función de limpieza llama a client.end(), la primera conexión se cierra al instante y solo queda una abierta. En producción el componente se monta una sola vez.

¿Es seguro poner credenciales MQTT en variables NEXT_PUBLIC_?

Solo credenciales que no te importe que lea cualquier visitante. Los valores NEXT_PUBLIC_ se incrustan en el bundle de JavaScript. Dale al navegador un usuario limitado por ACL a topics de solo lectura y guarda las credenciales de publicación en variables de entorno solo de servidor que usen los Route Handlers.