DEV Community

Draginox
Draginox

Posted on

How I Built a Real-Time Minecraft Server Status Checker with Next.js 15 and Server-Sent Events

I needed to build a real-time Minecraft server status checker for Minecraft ServerHub — a server list with 1,000+ servers. The naive approach (polling an API every few seconds) felt wasteful. So I went with Server-Sent Events (SSE) to stream results back to the client as soon as they arrive.

Here's exactly how it works, including the raw TCP protocol implementation.

The Minecraft Server List Ping Protocol

Minecraft Java servers use a custom binary TCP protocol for status pings. You have to:

  1. Open a raw TCP socket to port 25565
  2. Send a Handshake packet (packet ID 0x00) to set up the connection
  3. Send a Status Request packet (packet ID 0x00 again, but in the Status state)
  4. Read the Status Response — a JSON payload with player count, version, MOTD, etc.

The binary format uses variable-length integers (VarInts) — each byte uses 7 bits for data and 1 bit to indicate if more bytes follow.

Here's the TypeScript implementation:

// lib/minecraft/ping.ts
import net from "net";

function writeVarInt(value: number): Buffer {
  const bytes: number[] = [];
  do {
    let byte = value & 0x7f;
    value >>>= 7;
    if (value !== 0) byte |= 0x80;
    bytes.push(byte);
  } while (value !== 0);
  return Buffer.from(bytes);
}

function readVarInt(buf: Buffer, offset: number): [number, number] {
  let result = 0;
  let shift = 0;
  let pos = offset;
  while (true) {
    const byte = buf[pos++];
    result |= (byte & 0x7f) << shift;
    if ((byte & 0x80) === 0) break;
    shift += 7;
  }
  return [result, pos];
}

export async function pingJavaServer(
  host: string,
  port = 25565,
  timeoutMs = 5000
): Promise<MinecraftStatus> {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host, port });
    socket.setTimeout(timeoutMs);

    socket.once("connect", () => {
      const hostBuf = Buffer.from(host, "utf8");
      const hostLen = writeVarInt(hostBuf.length);
      const handshakeData = Buffer.concat([
        writeVarInt(0x00),
        writeVarInt(767),
        hostLen,
        hostBuf,
        Buffer.from([0x63, 0xdd]),
        writeVarInt(1),
      ]);
      const handshakePacket = Buffer.concat([
        writeVarInt(handshakeData.length),
        handshakeData,
      ]);
      const statusRequest = Buffer.from([0x01, 0x00]);
      socket.write(Buffer.concat([handshakePacket, statusRequest]));
    });

    let rawData = Buffer.alloc(0);
    socket.on("data", (chunk) => {
      rawData = Buffer.concat([rawData, chunk]);
      try {
        const [packetLength, offset1] = readVarInt(rawData, 0);
        if (rawData.length < offset1 + packetLength) return;
        const [, offset2] = readVarInt(rawData, offset1);
        const [jsonLength, offset3] = readVarInt(rawData, offset2);
        const jsonStr = rawData.slice(offset3, offset3 + jsonLength).toString("utf8");
        const data = JSON.parse(jsonStr);
        socket.destroy();
        resolve({
          online: true,
          players: { online: data.players?.online ?? 0, max: data.players?.max ?? 0 },
          version: data.version?.name ?? "Unknown",
          motd: data.description,
          latencyMs: Date.now() - connectTime,
        });
      } catch {}
    });

    const connectTime = Date.now();
    socket.on("timeout", () => { socket.destroy(); reject(new Error("Timeout")); });
    socket.on("error", reject);
  });
}
Enter fullscreen mode Exit fullscreen mode

The SSE API Route (Next.js 15 App Router)

Instead of blocking until we have a result, the API route streams events back as soon as data arrives:

// app/api/ping/route.ts
import { NextRequest } from "next/server";
import { redis } from "@/lib/redis";
import { pingJavaServer } from "@/lib/minecraft/ping";

export const runtime = "nodejs"; // required for raw TCP sockets

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const host = searchParams.get("host") ?? "";
  const port = parseInt(searchParams.get("port") ?? "25565");

  if (!host) return new Response("Missing host", { status: 400 });

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) => {
        controller.enqueue(
          encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
        );
      };

      try {
        send("status", { stage: "connecting", message: "Connecting to server…" });

        const cacheKey = `ping:${host}:${port}`;
        const cached = await redis.get(cacheKey);
        if (cached) {
          send("result", { ...JSON.parse(cached), cached: true });
          controller.close();
          return;
        }

        send("status", { stage: "checking", message: "Pinging server…" });
        const result = await pingJavaServer(host, port);
        await redis.setex(cacheKey, 60, JSON.stringify(result));
        send("result", result);
      } catch (err) {
        send("error", {
          online: false,
          message: err instanceof Error ? err.message : "Server unreachable",
        });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

The React Client Component

// components/ServerStatusChecker.tsx
"use client";
import { useState, useCallback } from "react";

export function ServerStatusChecker() {
  const [host, setHost] = useState("");
  const [stage, setStage] = useState("idle");
  const [result, setResult] = useState(null);

  const checkServer = useCallback(async () => {
    setStage("connecting");
    setResult(null);
    const sse = new EventSource(`/api/ping?host=${encodeURIComponent(host)}`);

    sse.addEventListener("status", (e) => {
      setStage(JSON.parse(e.data).stage);
    });
    sse.addEventListener("result", (e) => {
      setResult(JSON.parse(e.data));
      setStage("done");
      sse.close();
    });
    sse.addEventListener("error", (e) => {
      setResult(e.data ? JSON.parse(e.data) : null);
      setStage("error");
      sse.close();
    });
  }, [host]);

  return (
    <div>
      <input value={host} onChange={(e) => setHost(e.target.value)} placeholder="mc.hypixel.net" />
      <button onClick={checkServer}>Check</button>
      {result && (
        <div>
          <p>Status: {result.online ? "🟢 Online" : "🔴 Offline"}</p>
          {result.online && <p>Players: {result.players.online}/{result.players.max}</p>}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cleaning MOTD Color Codes

Minecraft MOTDs use § color codes (e.g., §aGreen text§r). Strip them for plain text:

export function cleanMotd(motd: unknown): string {
  if (typeof motd === "string") {
    return motd.replace([0-9a-fklmnor]/gi, "").trim();
  }
  if (typeof motd === "object" && motd !== null && "extra" in motd) {
    const parts = (motd as { extra?: Array<{ text: string }> }).extra ?? [];
    return parts.map((p) => p.text ?? "").join("").trim();
  }
  return "";
}
Enter fullscreen mode Exit fullscreen mode

Why SSE Instead of WebSockets?

  • SSE is unidirectional (server → client only) — perfect for "fire a query, stream the result"
  • No special server needed — works over standard HTTP
  • Built-in reconnect — browsers auto-reconnect if the connection drops
  • Simpler auth — cookies and headers work the same as regular HTTP

WebSockets make sense for bidirectional communication. For a status checker, SSE is simpler and equally fast.

The Live Tool

You can try the finished tool at minecraft-serverhub.com/tools/server-status-checker — it checks any Java or Bedrock server in real time and shows the MOTD with formatting.

The full API documentation is also available if you want to build your own integration (no API key required, completely free).


If you have questions about the Minecraft ping protocol or the SSE implementation, drop them in the comments. Happy coding!

Top comments (0)