Monitoring Next.js with Prometheus in Kubernetes
Wiring Prometheus into a Next.js app via Server Actions and running the whole thing on a local Kind cluster.
I wanted Prometheus metrics out of a Next.js Server Action, and the path from prom-client to a scraped target in App Router is less obvious than it should be. Server Actions aren’t HTTP handlers you can wrap, and Next.js’s dev-mode module reloading breaks the lazy registration patterns that work fine in a long-lived Node server. Both have one-line fixes once you know they’re there.
This post is the version that ended up in production-shaped form: a Server Action emitting metrics, a route handler exposing them, and a Kind cluster running a Prometheus that scrapes the result. Code at mustafa-zidan/nextjs-prometheus.
Where the App Router model breaks the usual setup
The standard prom-client integration in an Express or Koa app is a middleware: register metrics at module scope, wrap each handler to observe duration, expose /metrics. None of the three steps survive the move to App Router cleanly.
Server Actions are invoked by the framework, not by a handler chain, so there’s nothing for middleware to wrap. Route handlers expose one, but a Server Action and its receiving route handler are different layers; the action body runs before any response is constructed, and there’s no shared interceptor between them. The right place to observe is inside whatever function is doing the work, not around it.
Module-scope registration has its own catch. In production it does what you’d expect: the module loads once, the registry holds the metric, every handler invocation observes against the same instance. In dev, Next.js hot-reloads modules on every file change, and a second new client.Summary(...) call against an already-registered name throws. The fix is a getSingleMetric guard around registration: trivial once you know about it, frustrating when the dev server is falling over on every save and the stack trace points at framework code.
Both fixes follow the same shape: register once at module scope, observe inside the handler, and add a guard so re-registration is harmless.
The setup
import { NextRequest, NextResponse } from "next/server";
import client from "prom-client";
const requestSummary = new client.Summary({
name: "app_elapsed_time_seconds",
help: "Time difference between start and stop tracking",
labelNames: ["label_1", "label_2", "label_3", "label_4", "label_5", "label_6"],
});
if (!client.register.getSingleMetric("app_elapsed_time_seconds")) {
client.register.registerMetric(requestSummary);
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { label_1, label_2, label_3, label_4, label_5, label_6, value } = body;
if (!label_1 || !label_2 || !label_3 || !label_4 || !label_5 || !label_6 || typeof value !== "number") {
return NextResponse.json({ error: "Missing or invalid parameters" }, { status: 400 });
}
requestSummary
.labels(label_1, label_2, label_3, label_4, label_5, label_6)
.observe(value);
return NextResponse.json({ message: "Metric recorded", duration: value }, { status: 200 });
} catch {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
Six labels is overshoot for a real metric. Cardinality is how Prometheus turns into expensive Prometheus, and anything user-derived (IDs, URLs with parameters, free-text) is a landmine. Two or three labels with bounded value sets is the working ceiling; the six here exist to make the .labels(...).observe() propagation visible.
The scrape endpoint is one route handler:
export async function GET() {
return new Response(await client.register.metrics(), {
headers: { "Content-Type": client.register.contentType },
});
}
register.metrics() returns the text exposition format Prometheus expects. The content-type comes from client.register.contentType rather than a hard-coded string because the library negotiates between the older text/plain format and OpenMetrics; getting it wrong makes Prometheus silently reject the scrape.
Running it on Kind
A single-node Kind cluster is enough to exercise the scrape path against a real Kubernetes service, which is the bit that doesn’t surface under npm run dev.
kind create cluster --name monitoring-cluster
docker build -t nextjs-prometheus .
kind load docker-image nextjs-prometheus --name monitoring-cluster
kubectl apply -f k8s/demo.yaml
kubectl apply -f k8s/prometheus-config.yaml
kubectl apply -f k8s/prometheus-deployment.yaml
kubectl apply -f k8s/prometheus-service.yaml
The non-obvious line is kind load docker-image. Kind nodes are containers; they don’t share an image cache with the host’s Docker daemon, so any locally-built image has to be loaded into the cluster explicitly. Skip that step and kubectl apply succeeds while the pod sits in ImagePullBackOff forever.
The Prometheus config in the repo defines one scrape job against the Next.js service’s /api/metrics path. Confirm end-to-end with a port-forward:
kubectl port-forward -n monitoring service/prometheus 9090:9090
# http://localhost:9090, query: app_elapsed_time_seconds
Samples landing in Prometheus after a few interactions in the UI mean the chain is intact: Server Action → POST handler → in-process registry → GET /api/metrics → scrape.
Where this still needs work
Instrumenting one endpoint by hand is fine for a demo and tiresome for a real app. The version that scales is a thin wrapper around the Server Action body (histogram.startTimer() on entry, label by action name, error counter incremented on throw), small enough to inline at the top of each action file, opinionated enough to keep cardinality predictable.