Home / Articles / Typed Contracts and Zod Guards for a Live WebSocket Analytics Dashboard

This article is published in English.

Typed Contracts and Zod Guards for a Live WebSocket Analytics Dashboard

How to build a trustworthy real-time dashboard in TypeScript: define payload contracts, validate WebSocket messages with Zod, and stop duplicate connections.

1298 words

A request for "live numbers, right now" sounds like a charting problem, but it is mostly a data-trust problem. When metrics arrive as untyped JSON, when two endpoints name the same field differently and when sockets reconnect in loops, the dashboard looks busy but nobody believes it. This walkthrough follows a small real-time analytics dashboard built with TypeScript and shows the patterns that make it reliable: a typed contract, validation at the socket boundary, a guarded connection and a deliberately plain layout.

Why the untyped version could not be trusted

Consider a typical starting point: a half-finished admin panel written in loose JavaScript. The symptoms are familiar:

  • Values flow through the code as any, so the editor offers no help.
  • The chart library receives whatever shape the server happened to send.
  • One API returns user, another users_count, for similar ideas.
  • NaN shows up in the UI whenever a field is missing or malformed.

None of these are exotic bugs. They share one root cause: there is no agreed contract between the data source and the UI. The fix is a single rule the whole team can apply: if a data shape is not typed and checked, it does not reach the UI.

Scoping a dashboard people will actually use

Impressive-looking dashboards and useful dashboards are rarely the same thing. A tight first version might include only:

  1. The number of visitors right now
  2. Conversion rate over the last 24 hours
  3. The most visited pages
  4. The current error rate
  5. A "last updated" indicator so viewers know the data is fresh

The stack stays equally focused:

  • Next.js with the App Router
  • TypeScript in strict mode
  • Recharts for the charts
  • WebSockets for pushing updates
  • Zod to validate every payload before React sees it

The goal is not a perfect product. It is a set of numbers the team stops arguing about.

Writing the data contract first

Instead of fetching JSON and hoping it matches, start by describing exactly what the UI expects. The types below cover a generic metric (with its change percentage and an ISO timestamp) and the complete live payload that the socket delivers.

type DashboardMetric = {
  id: string;
  label: string;
  value: number;
  deltaPercent: number;
  updatedAt: string; // ISO
};
type LiveDashboardPayload = {
  visitorsNow: number;
  conversionRate: number;
  topPages: Array<{ path: string; views: number }>;
  errorRate: number;
  metrics: DashboardMetric[];
};

These types document intent and give autocompletion, but they vanish at runtime. A WebSocket message is just a string, and TypeScript cannot check what a server sends. That is why the next step matters.

Validating every socket message with Zod

The Zod schema mirrors the contract and adds rules that types cannot express: counts cannot be negative, page views must be integers, and conversion and error rates are fractions between 0 and 1. The updatedAt field must be a valid datetime string.

import { z } from "zod";
const LiveDashboardSchema = z.object({
  visitorsNow: z.number().nonnegative(),
  conversionRate: z.number().min(0).max(1),
  topPages: z.array(
    z.object({
      path: z.string(),
      views: z.number().int().nonnegative(),
    })
  ),
  errorRate: z.number().min(0).max(1),
  metrics: z.array(
    z.object({
      id: z.string(),
      label: z.string(),
      value: z.number(),
      deltaPercent: z.number(),
      updatedAt: z.string().datetime(),
    })
  ),
});

With this in place, a malformed payload no longer crashes the page or leaks NaN into a chart. It is rejected and the last good state stays on screen.

Maintaining both the hand-written types and the schema invites drift. A common refinement is to treat the schema as the source of truth and derive the type with z.infer<typeof LiveDashboardSchema>. Also check your Zod version: recent releases offer z.iso.datetime() as the preferred form of the datetime check, so confirm the API against the current docs. For a deeper look at sharing one schema across layers, see using a single Zod schema on the frontend and backend.

Taming reconnects and duplicate listeners

Real-time features tend to fail in a specific way. A naive first version reconnects endlessly, attaches a new message handler on every attempt, stacks chart updates on top of stale ones and eventually bogs down the browser.

The cure is to think of the connection as a small state machine: idle, then connecting, then live, dropping to reconnecting and back to live when the network recovers. The most important rule is that only one socket may exist at a time. The connect function below enforces it: if a socket is already open or opening, it returns immediately. Incoming messages are parsed with safeParse, which returns a result object instead of throwing, so invalid data is logged and skipped while valid data updates state.

let socket: WebSocket | null = null;

function connect() {
  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
    return;
  }

  socket = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);

  socket.onmessage = (event) => {
    const parsed = LiveDashboardSchema.safeParse(JSON.parse(event.data));
    if (!parsed.success) {
      console.warn("Invalid live payload", parsed.error);
      return;
    }
    setDashboard(parsed.data);
  };
}

A few gaps are worth closing before production. JSON.parse can itself throw on a non-JSON frame, so wrap it in try/catch. The snippet shows the guard but not the reconnect path; add onclose handling with a backoff delay so a server outage does not trigger a tight reconnection loop. In React, close the socket in an effect cleanup so remounts (including the double invocation of effects in development Strict Mode) do not leak connections.

Designing for the question "where do I look first?"

It is tempting to dress a live dashboard up with gradients, glowing cards and many colors. A better test is to ask a stakeholder where their eyes should go first, then remove everything that does not answer that. A layout that works well is:

  • A row of at most four headline metrics
  • One primary chart
  • One table
  • A single status line such as Live • updated 2s ago

Typing helps here too. When every metric has a defined shape, the UI cannot sprout ad-hoc widgets for data nobody specified. The constraints keep the design honest.

What users notice after launch

Once a dashboard like this ships, the feedback is rarely about architecture. People say they finally trust the numbers, that the page no longer freezes, and they are surprised it is genuinely live. That is the real job of a dashboard: not a gallery of charts, but a tool people can rely on in a meeting.

Key takeaways

  • Type the boundary and validate every external payload at runtime; TypeScript alone cannot see what the server sends.
  • Keep strict mode on; it pays off every time the code changes.
  • Treat a live connection as a state machine and allow exactly one socket.
  • Remove interface elements until the main story is obvious.
  • Prefer a plain view with correct data over a polished view with doubtful data.

If you are building your first live dashboard, resist starting big. Start with a single typed payload checked by a Zod schema, show three numbers alongside a freshness timestamp, and bring in sockets only once that foundation holds.