MQTT in Next.js works best split in two: the browser subscribes to live data over secure WebSockets from a Client Component, and anything that needs secrets or writes to devices goes through a Route Handler that publishes from the server. This guide builds both halves with MQTT.js 5 in the App Router, plus an optional Server-Sent Events route for when the browser should not talk to the broker at all.
The examples use broker.connectmq.com (WebSocket 8084 with path /mqtt, TLS 8883) and test topics under testmqtt/nextjs/.... Swap in your own host and credentials.
Where MQTT runs in a Next.js app
| Code | Runs on | Transport | Use it for |
|---|---|---|---|
Client Component ("use client") | Browser | wss://…:8084/mqtt | Live dashboards, notifications |
Route Handler (app/**/route.ts) | Node.js server | mqtts://…:8883 | Commands, writes, secret credentials |
| Server Component | Server, per render | Avoid MQTT | Initial data from a database or API |
Server Components render once per request (or at build time) and then finish, which is the wrong lifetime for a subscription. Fetch the initial state from your database, then let a Client Component stream updates.
Install the client:
npm install mqtt server-onlyLive data in a Client Component
The browser cannot open a TCP socket, so the only option is MQTT over WebSockets. Create the connection inside useEffect so it never runs during server rendering, and close it in the cleanup function:
"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 it from a normal Server Component page; params is a promise in current Next.js:
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>
);
}Things that trip people up in this half:
- Two connections in development. React Strict Mode runs the effect, its cleanup and the effect again. With
client.end(true)in the cleanup the first connection closes at once. Without it, each hot reload leaks a connection. - Mixed content. A page served over HTTPS may only open
wss://sockets.ws://broker...:8083works onhttp://localhostand fails in production withWebSocket connection to 'ws://…' failed. - Wrong path. MQTT over WebSockets needs the broker's path (
/mqtthere). A missing path usually ends in an HTTP 404 during the upgrade; see WebSocket path 404 and WebSocket connection failed. - Subscribing in
connect. It runs again after every reconnect, which is what you want when the session is not persistent. Subscribing twice to the same filter is harmless.
If several components need the same stream, lift the client into a context provider instead of opening one connection per component; the pattern is the same as in MQTT with React.
What the browser is allowed to do
Everything prefixed with NEXT_PUBLIC_ is inlined into the bundle at build time, so treat those credentials as public. Give the browser a dedicated user whose ACL allows subscribe only on the topics the dashboard shows. Publishing commands goes through the server, below. WebSocket authentication in the browser covers short-lived tokens if you need per-user access.
Publishing from a Route Handler
Route Handlers run on the Node.js runtime (the default), so they can use MQTT over TLS on port 8883. Opening a new connection per request costs a TCP and TLS handshake every time, so cache one client per server process. Storing it on globalThis also survives hot module reloads in next dev:
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" turns an accidental import from a Client Component into a build error, so the publishing credentials never reach the browser. The random client ID matters: several server instances sharing one ID would keep kicking each other off the broker (duplicate client ID).
The handler validates the input, publishes with QoS 1 and waits for the PUBACK before answering:
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 });
}
}The device ID check is not cosmetic: a value containing /, + or # would let a caller write to a different topic than you intended. Check your filters with the topic matcher.
A Server Action works the same way: mark a function in a server file with "use server", call getMqtt() inside it and publish. Use whichever fits your form handling; the MQTT part is identical.
Serverless vs. long-running servers
On next start, in Docker or on any long-running Node.js host, the cached client stays connected and each publish is a single round trip. On serverless platforms a function instance can be frozen between requests; the socket may be dead when it wakes up, and MQTT.js reconnects before the publish goes out. That works, but the first request after a pause pays the reconnect. If commands are rare, that is fine. If they are frequent, prefer a long-running deployment.
Streaming to the browser without a broker connection
Sometimes the browser should not talk to the broker at all: strict firewalls, no browser credentials, or you want to filter data per user. A Route Handler can subscribe on the server and forward messages as Server-Sent Events:
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" },
});
}In the browser, new EventSource("/api/devices/dev-1/stream") receives each message as an event. This holds one MQTT connection per open tab, so it suits internal tools and modest audiences on a long-running server; for large audiences the direct WebSocket approach scales better because the broker does the fan-out.
Test it end to end
- Run
next devand open/devices/dev-1. The status should readconnected. - Open the online MQTT client in another tab, connect to
broker.connectmq.comand publish{"temp":21.4}totestmqtt/nextjs/dev-1/telemetry. The page updates immediately. - Subscribe in the online client to
testmqtt/nextjs/+/command, then call the Route Handler:
curl -X POST http://localhost:3000/api/devices/dev-1/command \
-H "Content-Type: application/json" \
-d '{"action":"identify"}'The command appears in the online client, and the response is {"ok":true}. Try {"action":"format-disk"} to confirm the handler rejects unknown actions, and stop your network briefly to watch the status switch to reconnecting and back.
Watch the Next.js test topics live
Keep subscriptions in Client Components over secure WebSockets, keep secrets and writes in Route Handlers with one cached server client, and reach for SSE only when the browser must stay away from the broker. With that split, the same MQTT.js code runs in next dev, in CI and in production with nothing more than different environment variables.
Frequently asked questions
Can a Next.js page connect to MQTT over port 1883?
Not from the browser. Browsers cannot open raw TCP sockets, so a Client Component must use MQTT over WebSockets (wss://broker.connectmq.com:8084/mqtt). Server code in Route Handlers runs on Node.js and can use mqtt:// on 1883 or mqtts:// on 8883.
Why do I see two MQTT connections in development?
React Strict Mode mounts, unmounts and remounts every component in development, so your useEffect runs twice. If the cleanup function calls client.end(), the first connection closes immediately and only one stays open. Production builds mount once.
Is it safe to put MQTT credentials in NEXT_PUBLIC_ variables?
Only credentials you are happy for every visitor to read. NEXT_PUBLIC_ values are inlined into the JavaScript bundle. Give the browser a user restricted by ACLs to read-only topics, and keep publishing credentials in server-only environment variables used by Route Handlers.


