Tutoriais

MQTT no Next.js: Client Components e rotas de servidor

Use MQTT no App Router do Next.js: dados ao vivo via WebSockets seguros em um Client Component, publicação em Route Handlers, cliente em cache e SSE.

Atualizado · 9 min de leitura

MQTT no Next.js: Client Components e rotas de servidor

MQTT no Next.js funciona melhor dividido em duas partes: o navegador assina dados ao vivo via WebSockets seguros a partir de um Client Component, e tudo o que precisa de segredos ou escreve nos dispositivos passa por um Route Handler que publica a partir do servidor. Este guia monta as duas partes com MQTT.js 5 no App Router, além de uma rota opcional de Server-Sent Events para quando o navegador não deve falar com o broker de jeito nenhum.

Os exemplos usam broker.connectmq.com (WebSocket 8084 com o caminho /mqtt, TLS 8883) e topics de teste em testmqtt/nextjs/.... Troque pelo seu próprio host e suas credenciais.

Onde o MQTT roda em um app Next.js

CódigoRoda emTransporteUse para
Client Component ("use client")Navegadorwss://…:8084/mqttDashboards ao vivo, notificações
Route Handler (app/**/route.ts)Servidor Node.jsmqtts://…:8883Comandos, escritas, credenciais secretas
Server ComponentServidor, a cada renderEvite MQTTDados iniciais de um banco ou API

Server Components renderizam uma vez por requisição (ou no build) e terminam, o que é o ciclo de vida errado para uma assinatura. Busque o estado inicial no seu banco de dados e deixe um Client Component receber as atualizações.

Instale o cliente:

Install MQTT.js
npm install mqtt server-only

Dados ao vivo em um Client Component

O navegador não consegue abrir um socket TCP, então a única opção é MQTT sobre WebSockets. Crie a conexão dentro de useEffect para que ela nunca rode durante a renderização no servidor e feche-a na função de cleanup:

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>
  );
}

Renderize-o a partir de uma página Server Component normal; no Next.js atual, params é uma promise:

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>
  );
}

O que costuma pegar as pessoas nesta parte:

  • Duas conexões em desenvolvimento. O React Strict Mode executa o efeito, o cleanup e o efeito de novo. Com client.end(true) no cleanup, a primeira conexão fecha imediatamente. Sem isso, cada hot reload deixa uma conexão vazando.
  • Conteúdo misto. Uma página servida por HTTPS só pode abrir sockets wss://. ws://broker...:8083 funciona em http://localhost e falha em produção com WebSocket connection to 'ws://…' failed.
  • Caminho errado. MQTT sobre WebSockets precisa do caminho do broker (aqui /mqtt). Sem ele, o resultado costuma ser um HTTP 404 durante o upgrade; veja WebSocket path 404 e WebSocket connection failed.
  • Assinar no connect. Ele roda de novo após cada reconexão, que é o que você quer quando a sessão não é persistente. Assinar duas vezes o mesmo filtro não causa problema.

Se vários componentes precisam do mesmo fluxo, suba o cliente para um context provider em vez de abrir uma conexão por componente; o padrão é o mesmo de MQTT com React.

O que o navegador pode fazer

Tudo com o prefixo NEXT_PUBLIC_ é embutido no bundle durante o build, então trate essas credenciais como públicas. Dê ao navegador um usuário dedicado cuja ACL permita apenas assinar os topics que o dashboard exibe. A publicação de comandos passa pelo servidor, como mostrado abaixo. Autenticação WebSocket no navegador cobre tokens de curta duração se você precisar de acesso por usuário.

Publicando a partir de um Route Handler

Route Handlers rodam no runtime Node.js (o padrão), então podem usar MQTT sobre TLS na porta 8883. Abrir uma conexão nova por requisição custa um handshake TCP e TLS toda vez, então mantenha um cliente em cache por processo de servidor. Guardá-lo em globalThis também sobrevive aos hot reloads do 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" transforma uma importação acidental em um Client Component em erro de build, então as credenciais de publicação nunca chegam ao navegador. O client ID aleatório é importante: várias instâncias de servidor com o mesmo ID ficariam derrubando umas às outras do broker (client ID duplicado).

O handler valida a entrada, publica com QoS 1 e espera o 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 });
  }
}

A verificação do ID do dispositivo não é cosmética: um valor contendo /, + ou # deixaria quem chama escrever em um topic diferente do pretendido. Confira seus filtros com o topic matcher.

Uma Server Action funciona do mesmo jeito: marque uma função em um arquivo de servidor com "use server", chame getMqtt() dentro dela e publique. Use o que combinar melhor com o seu tratamento de formulários; a parte MQTT é idêntica.

Serverless vs. servidores de longa duração

Com next start, em Docker ou em qualquer host Node.js de longa duração, o cliente em cache continua conectado e cada publicação é uma única ida e volta. Em plataformas serverless, uma instância de função pode ser congelada entre requisições; o socket pode estar morto quando ela acorda, e o MQTT.js reconecta antes de enviar a publicação. Funciona, mas a primeira requisição após uma pausa paga a reconexão. Se os comandos são raros, tudo bem. Se são frequentes, prefira um deploy de longa duração.

Streaming para o navegador sem conexão com o broker

Às vezes o navegador não deve falar com o broker de forma alguma: firewalls rígidos, nenhuma credencial no navegador ou a necessidade de filtrar dados por usuário. Um Route Handler pode assinar no servidor e repassar as mensagens 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" },
  });
}

No navegador, new EventSource("/api/devices/dev-1/stream") recebe cada mensagem como um evento. Isso mantém uma conexão MQTT por aba aberta, então serve para ferramentas internas e públicos modestos em um servidor de longa duração; para públicos grandes, a abordagem direta via WebSocket escala melhor, porque o broker cuida do fan-out.

Teste de ponta a ponta

  1. Rode next dev e abra /devices/dev-1. O status deve mostrar connected.
  2. Abra o cliente MQTT online em outra aba, conecte em broker.connectmq.com e publique {"temp":21.4} em testmqtt/nextjs/dev-1/telemetry. A página atualiza na hora.
  3. Assine testmqtt/nextjs/+/command no cliente online e chame o Route Handler:
Trigger a command
curl -X POST http://localhost:3000/api/devices/dev-1/command \
  -H "Content-Type: application/json" \
  -d '{"action":"identify"}'

O comando aparece no cliente online e a resposta é {"ok":true}. Tente {"action":"format-disk"} para confirmar que o handler rejeita ações desconhecidas, e desligue a rede por um instante para ver o status mudar para reconnecting e voltar.

Acompanhe os topics de teste do Next.js ao vivo

Mantenha as assinaturas em Client Components via WebSockets seguros, os segredos e as escritas em Route Handlers com um único cliente de servidor em cache, e use SSE só quando o navegador precisar ficar longe do broker. Com essa divisão, o mesmo código MQTT.js roda no next dev, no CI e em produção, mudando apenas as variáveis de ambiente.

Perguntas frequentes

Uma página Next.js pode se conectar ao MQTT pela porta 1883?

Pelo navegador, não. Navegadores não conseguem abrir sockets TCP diretos, então um Client Component precisa usar MQTT sobre WebSockets (wss://broker.connectmq.com:8084/mqtt). O código de servidor nos Route Handlers roda em Node.js e pode usar mqtt:// na 1883 ou mqtts:// na 8883.

Por que vejo duas conexões MQTT em desenvolvimento?

O React Strict Mode monta, desmonta e monta de novo cada componente em desenvolvimento, então seu useEffect roda duas vezes. Se a função de cleanup chamar client.end(), a primeira conexão fecha na hora e só uma fica aberta. Em produção o componente é montado uma única vez.

É seguro colocar credenciais MQTT em variáveis NEXT_PUBLIC_?

Só credenciais que qualquer visitante pode ler sem problema. Valores NEXT_PUBLIC_ são embutidos no bundle JavaScript. Dê ao navegador um usuário restrito por ACL a topics somente leitura e mantenha as credenciais de publicação em variáveis de ambiente exclusivas do servidor, usadas pelos Route Handlers.