Tutoriels

MQTT avec Next.js : Client Components et routes serveur

MQTT dans l'App Router de Next.js : données en direct via WebSockets sécurisés, publication depuis des Route Handlers, client serveur en cache et SSE.

Mis à jour · 9 min de lecture

MQTT avec Next.js : Client Components et routes serveur

MQTT avec Next.js fonctionne mieux en deux parties : le navigateur s'abonne aux données en direct via des WebSockets sécurisés depuis un Client Component, et tout ce qui demande des secrets ou écrit vers les appareils passe par un Route Handler qui publie depuis le serveur. Ce guide construit les deux moitiés avec MQTT.js 5 dans l'App Router, plus une route Server-Sent Events optionnelle pour les cas où le navigateur ne doit pas du tout parler au broker.

Les exemples utilisent broker.connectmq.com (WebSocket 8084 avec le chemin /mqtt, TLS 8883) et des topics de test sous testmqtt/nextjs/.... Remplacez-les par votre propre hôte et vos identifiants.

Où MQTT s'exécute dans une app Next.js

CodeS'exécute surTransportÀ utiliser pour
Client Component ("use client")Navigateurwss://…:8084/mqttDashboards en direct, notifications
Route Handler (app/**/route.ts)Serveur Node.jsmqtts://…:8883Commandes, écritures, identifiants secrets
Server ComponentServeur, à chaque renduÉvitez MQTTDonnées initiales depuis une base ou une API

Les Server Components sont rendus une fois par requête (ou au build) puis se terminent : ce n'est pas la bonne durée de vie pour un abonnement. Récupérez l'état initial dans votre base de données, puis laissez un Client Component recevoir les mises à jour.

Installez le client :

Install MQTT.js
npm install mqtt server-only

Données en direct dans un Client Component

Le navigateur ne peut pas ouvrir de socket TCP, la seule option est donc MQTT sur WebSockets. Créez la connexion dans useEffect pour qu'elle ne s'exécute jamais pendant le rendu serveur, et fermez-la dans la fonction de nettoyage :

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

Affichez-le depuis une page Server Component classique ; dans le Next.js actuel, params est une promesse :

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

Les pièges habituels de cette moitié :

  • Deux connexions en développement. React Strict Mode exécute l'effet, son nettoyage, puis l'effet à nouveau. Avec client.end(true) dans le nettoyage, la première connexion se ferme immédiatement. Sans cela, chaque hot reload laisse fuir une connexion.
  • Contenu mixte. Une page servie en HTTPS ne peut ouvrir que des sockets wss://. ws://broker...:8083 fonctionne sur http://localhost et échoue en production avec WebSocket connection to 'ws://…' failed.
  • Mauvais chemin. MQTT sur WebSockets a besoin du chemin du broker (ici /mqtt). Sans lui, l'upgrade se termine généralement par un HTTP 404 ; voir WebSocket path 404 et WebSocket connection failed.
  • S'abonner dans connect. Le handler se relance après chaque reconnexion, ce qui est exactement ce qu'il faut quand la session n'est pas persistante. S'abonner deux fois au même filtre est sans conséquence.

Si plusieurs composants ont besoin du même flux, remontez le client dans un context provider au lieu d'ouvrir une connexion par composant ; le principe est le même que dans MQTT avec React.

Ce que le navigateur a le droit de faire

Tout ce qui porte le préfixe NEXT_PUBLIC_ est intégré au bundle au moment du build : considérez ces identifiants comme publics. Donnez au navigateur un utilisateur dédié dont l'ACL autorise uniquement l'abonnement aux topics affichés par le dashboard. La publication de commandes passe par le serveur, comme décrit plus bas. L'authentification WebSocket dans le navigateur couvre les jetons de courte durée si vous avez besoin d'un accès par utilisateur.

Publier depuis un Route Handler

Les Route Handlers tournent sur le runtime Node.js (par défaut) et peuvent donc utiliser MQTT sur TLS, port 8883. Ouvrir une nouvelle connexion par requête coûte à chaque fois un handshake TCP et TLS : gardez plutôt un client en cache par processus serveur. Le stocker sur globalThis lui permet aussi de survivre aux 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" transforme un import accidentel depuis un Client Component en erreur de build : les identifiants de publication n'atteignent jamais le navigateur. Le client ID aléatoire compte : plusieurs instances serveur partageant le même ID s'éjecteraient mutuellement du broker (client ID dupliqué).

Le handler valide l'entrée, publie en QoS 1 et attend le PUBACK avant de répondre :

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 vérification de l'ID d'appareil n'est pas cosmétique : une valeur contenant /, + ou # permettrait à l'appelant d'écrire sur un autre topic que prévu. Vérifiez vos filtres avec le topic matcher.

Une Server Action fonctionne de la même façon : marquez une fonction d'un fichier serveur avec "use server", appelez getMqtt() à l'intérieur et publiez. Choisissez ce qui convient à votre gestion des formulaires ; la partie MQTT est identique.

Serverless ou serveurs de longue durée

Avec next start, dans Docker ou sur tout hôte Node.js de longue durée, le client en cache reste connecté et chaque publication ne coûte qu'un aller-retour. Sur les plateformes serverless, une instance de fonction peut être gelée entre deux requêtes ; au réveil, le socket peut être mort, et MQTT.js se reconnecte avant d'envoyer la publication. Cela fonctionne, mais la première requête après une pause paie la reconnexion. Pour des commandes rares, aucun souci. Pour des commandes fréquentes, préférez un déploiement de longue durée.

Streamer vers le navigateur sans connexion au broker

Parfois, le navigateur ne doit pas parler au broker du tout : pare-feu stricts, aucun identifiant côté navigateur, ou données à filtrer par utilisateur. Un Route Handler peut s'abonner côté serveur et relayer les messages en 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" },
  });
}

Dans le navigateur, new EventSource("/api/devices/dev-1/stream") reçoit chaque message comme un événement. Cela maintient une connexion MQTT par onglet ouvert : idéal pour des outils internes et des audiences modestes sur un serveur de longue durée. Pour de grandes audiences, l'approche WebSocket directe passe mieux à l'échelle, car c'est le broker qui assure le fan-out.

Tester de bout en bout

  1. Lancez next dev et ouvrez /devices/dev-1. Le statut doit afficher connected.
  2. Ouvrez le client MQTT en ligne dans un autre onglet, connectez-vous à broker.connectmq.com et publiez {"temp":21.4} sur testmqtt/nextjs/dev-1/telemetry. La page se met à jour immédiatement.
  3. Abonnez-vous dans le client en ligne à testmqtt/nextjs/+/command, puis appelez le Route Handler :
Trigger a command
curl -X POST http://localhost:3000/api/devices/dev-1/command \
  -H "Content-Type: application/json" \
  -d '{"action":"identify"}'

La commande apparaît dans le client en ligne et la réponse est {"ok":true}. Essayez {"action":"format-disk"} pour confirmer que le handler rejette les actions inconnues, et coupez brièvement le réseau pour voir le statut passer à reconnecting puis revenir.

Suivre en direct les topics de test Next.js

Gardez les abonnements dans les Client Components via WebSockets sécurisés, les secrets et les écritures dans les Route Handlers avec un seul client serveur en cache, et ne recourez au SSE que lorsque le navigateur doit rester à l'écart du broker. Avec ce découpage, le même code MQTT.js tourne dans next dev, en CI et en production, avec pour seule différence les variables d'environnement.

Questions fréquentes

Une page Next.js peut-elle se connecter à MQTT sur le port 1883 ?

Pas depuis le navigateur. Les navigateurs ne peuvent pas ouvrir de sockets TCP bruts, un Client Component doit donc utiliser MQTT sur WebSockets (wss://broker.connectmq.com:8084/mqtt). Le code serveur des Route Handlers tourne sur Node.js et peut utiliser mqtt:// sur le 1883 ou mqtts:// sur le 8883.

Pourquoi vois-je deux connexions MQTT en développement ?

React Strict Mode monte, démonte puis remonte chaque composant en développement, donc votre useEffect s'exécute deux fois. Si la fonction de nettoyage appelle client.end(), la première connexion se ferme aussitôt et une seule reste ouverte. Les builds de production ne montent qu'une fois.

Peut-on mettre des identifiants MQTT dans des variables NEXT_PUBLIC_ ?

Uniquement des identifiants que tout visiteur peut lire. Les valeurs NEXT_PUBLIC_ sont intégrées au bundle JavaScript. Donnez au navigateur un utilisateur limité par ACL à des topics en lecture seule et gardez les identifiants de publication dans des variables d'environnement côté serveur, utilisées par les Route Handlers.