Tutorials

MQTT in Next.js: Client Components und Server-Routen

MQTT im Next.js App Router: Live-Daten über sichere WebSockets im Client Component, Publish aus Route Handlers, ein gecachter Server-Client und SSE.

Aktualisiert · 9 Min. Lesezeit

MQTT in Next.js: Client Components und Server-Routen

MQTT in Next.js funktioniert am besten zweigeteilt: Der Browser abonniert Live-Daten über sichere WebSockets aus einem Client Component, und alles, was Secrets braucht oder an Geräte schreibt, läuft über einen Route Handler, der vom Server aus publiziert. Diese Anleitung baut beide Hälften mit MQTT.js 5 im App Router, dazu eine optionale Server-Sent-Events-Route für den Fall, dass der Browser gar nicht mit dem Broker sprechen soll.

Die Beispiele nutzen broker.connectmq.com (WebSocket 8084 mit Pfad /mqtt, TLS 8883) und Test-Topics unter testmqtt/nextjs/.... Ersetze Host und Zugangsdaten durch deine eigenen.

Wo MQTT in einer Next.js-App läuft

CodeLäuft aufTransportEinsatz
Client Component ("use client")Browserwss://…:8084/mqttLive-Dashboards, Benachrichtigungen
Route Handler (app/**/route.ts)Node.js-Servermqtts://…:8883Befehle, Schreibzugriffe, geheime Zugangsdaten
Server ComponentServer, pro RenderMQTT vermeidenInitiale Daten aus Datenbank oder API

Server Components rendern einmal pro Request (oder beim Build) und sind dann fertig – die falsche Lebensdauer für eine Subscription. Hole den Anfangszustand aus deiner Datenbank und lass ein Client Component die Updates streamen.

Installiere den Client:

Install MQTT.js
npm install mqtt server-only

Live-Daten in einem Client Component

Der Browser kann keinen TCP-Socket öffnen, also bleibt nur MQTT über WebSockets. Baue die Verbindung in useEffect auf, damit sie nie beim Server-Rendering läuft, und schließe sie in der Cleanup-Funktion:

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

Gerendert wird es aus einer normalen Server-Component-Seite; params ist im aktuellen Next.js ein 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>
  );
}

Typische Stolperfallen in dieser Hälfte:

  • Zwei Verbindungen in der Entwicklung. React Strict Mode führt den Effect, sein Cleanup und den Effect erneut aus. Mit client.end(true) im Cleanup schließt die erste Verbindung sofort. Ohne das hinterlässt jeder Hot Reload eine verwaiste Verbindung.
  • Mixed Content. Eine per HTTPS ausgelieferte Seite darf nur wss://-Sockets öffnen. ws://broker...:8083 funktioniert auf http://localhost und scheitert in Produktion mit WebSocket connection to 'ws://…' failed.
  • Falscher Pfad. MQTT über WebSockets braucht den Pfad des Brokers (hier /mqtt). Fehlt er, endet das Upgrade meist mit HTTP 404; siehe WebSocket path 404 und WebSocket connection failed.
  • Subscribe im connect-Handler. Er läuft nach jedem Reconnect erneut – genau richtig, wenn die Session nicht persistent ist. Zweimal denselben Filter zu abonnieren schadet nicht.

Brauchen mehrere Komponenten denselben Stream, verschiebe den Client in einen Context Provider, statt pro Komponente eine Verbindung zu öffnen; das Muster ist dasselbe wie bei MQTT mit React.

Was der Browser darf

Alles mit dem Präfix NEXT_PUBLIC_ wird beim Build ins Bundle eingebettet, behandle diese Zugangsdaten also als öffentlich. Gib dem Browser einen eigenen Benutzer, dessen ACL nur Subscribe auf die Topics erlaubt, die das Dashboard zeigt. Befehle werden über den Server publiziert, siehe unten. WebSocket-Authentifizierung im Browser behandelt kurzlebige Tokens, falls du Zugriff pro Benutzer brauchst.

Publizieren aus einem Route Handler

Route Handlers laufen in der Node.js-Runtime (Standard) und können daher MQTT über TLS auf Port 8883 nutzen. Eine neue Verbindung pro Request kostet jedes Mal einen TCP- und TLS-Handshake, also cache einen Client pro Serverprozess. Auf globalThis abgelegt übersteht er auch Hot Module Reloads in 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" macht aus einem versehentlichen Import in ein Client Component einen Build-Fehler, sodass die Publish-Zugangsdaten nie im Browser landen. Die zufällige Client-ID ist wichtig: Mehrere Serverinstanzen mit derselben ID würden sich gegenseitig vom Broker werfen (doppelte Client-ID).

Der Handler validiert die Eingabe, publiziert mit QoS 1 und wartet auf das PUBACK, bevor er antwortet:

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

Die Prüfung der Geräte-ID ist keine Kosmetik: Ein Wert mit /, + oder # würde dem Aufrufer erlauben, in ein anderes Topic zu schreiben als vorgesehen. Prüfe deine Filter mit dem Topic Matcher.

Eine Server Action funktioniert genauso: Markiere eine Funktion in einer Server-Datei mit "use server", rufe darin getMqtt() auf und publiziere. Nimm, was besser zu deiner Formularverarbeitung passt; der MQTT-Teil ist identisch.

Serverless vs. langlebige Server

Mit next start, in Docker oder auf jedem langlebigen Node.js-Host bleibt der gecachte Client verbunden, und jeder Publish ist ein einziger Roundtrip. Auf Serverless-Plattformen kann eine Funktionsinstanz zwischen Requests eingefroren werden; beim Aufwachen ist der Socket womöglich tot, und MQTT.js verbindet sich neu, bevor der Publish rausgeht. Das funktioniert, aber der erste Request nach einer Pause bezahlt den Reconnect. Bei seltenen Befehlen ist das in Ordnung. Bei häufigen bevorzuge ein langlebiges Deployment.

Streaming zum Browser ohne Broker-Verbindung

Manchmal soll der Browser gar nicht mit dem Broker sprechen: strenge Firewalls, keine Zugangsdaten im Browser oder Daten, die pro Benutzer gefiltert werden müssen. Ein Route Handler kann auf dem Server abonnieren und Nachrichten als Server-Sent Events weiterreichen:

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

Im Browser empfängt new EventSource("/api/devices/dev-1/stream") jede Nachricht als Event. Das hält eine MQTT-Verbindung pro offenem Tab, passt also zu internen Tools und überschaubarem Publikum auf einem langlebigen Server; für großes Publikum skaliert der direkte WebSocket-Weg besser, weil der Broker das Fan-out übernimmt.

End-to-End testen

  1. Starte next dev und öffne /devices/dev-1. Der Status sollte connected zeigen.
  2. Öffne den Online-MQTT-Client in einem zweiten Tab, verbinde dich mit broker.connectmq.com und publiziere {"temp":21.4} auf testmqtt/nextjs/dev-1/telemetry. Die Seite aktualisiert sich sofort.
  3. Abonniere im Online-Client testmqtt/nextjs/+/command und rufe dann den Route Handler auf:
Trigger a command
curl -X POST http://localhost:3000/api/devices/dev-1/command \
  -H "Content-Type: application/json" \
  -d '{"action":"identify"}'

Der Befehl erscheint im Online-Client, die Antwort lautet {"ok":true}. Probiere {"action":"format-disk"}, um zu prüfen, dass der Handler unbekannte Aktionen ablehnt, und trenne kurz das Netzwerk, um zu sehen, wie der Status auf reconnecting und zurück wechselt.

Next.js-Test-Topics live beobachten

Halte Subscriptions in Client Components über sichere WebSockets, Secrets und Schreibzugriffe in Route Handlers mit einem gecachten Server-Client, und greif nur dann zu SSE, wenn der Browser vom Broker fernbleiben muss. Mit dieser Aufteilung läuft derselbe MQTT.js-Code in next dev, in CI und in Produktion – nur mit anderen Umgebungsvariablen.

Häufig gestellte Fragen

Kann sich eine Next.js-Seite über Port 1883 mit MQTT verbinden?

Aus dem Browser nicht. Browser können keine rohen TCP-Sockets öffnen, deshalb muss ein Client Component MQTT über WebSockets nutzen (wss://broker.connectmq.com:8084/mqtt). Server-Code in Route Handlers läuft auf Node.js und kann mqtt:// auf 1883 oder mqtts:// auf 8883 verwenden.

Warum sehe ich in der Entwicklung zwei MQTT-Verbindungen?

React Strict Mode mountet, unmountet und mountet jede Komponente in der Entwicklung erneut, daher läuft dein useEffect zweimal. Ruft die Cleanup-Funktion client.end() auf, schließt sich die erste Verbindung sofort und nur eine bleibt offen. Produktions-Builds mounten nur einmal.

Ist es sicher, MQTT-Zugangsdaten in NEXT_PUBLIC_-Variablen abzulegen?

Nur Zugangsdaten, die jeder Besucher lesen darf. NEXT_PUBLIC_-Werte werden ins JavaScript-Bundle eingebettet. Gib dem Browser einen Benutzer, der per ACL auf lesende Topics beschränkt ist, und halte Publish-Zugangsdaten in reinen Server-Umgebungsvariablen, die nur Route Handlers verwenden.