Tutorial

Next.js WebSocket tutorial: build a real-time market dashboard

The fastest way to leak a market-data key is to open the WebSocket from the browser. Keep one socket on the server, fan it out, and the key never leaves your machine.

On this page
  1. Why a Next.js WebSocket belongs on the server
  2. Project setup
  3. The market hub: one upstream WebSocket per server
  4. The route handler: Server-Sent Events
  5. A React hook for the live quotes
  6. What one viewer costs
  7. Deploying it
  8. Questions

Key takeaways

  • A Next.js WebSocket for market data belongs on the server: the stream URL carries the key in `?apiKey=`, so a browser connection exposes it to every visitor.
  • App Router route handlers cannot accept WebSocket upgrades, so relay the data to the browser as Server-Sent Events from a route handler.
  • One upstream connection per server process serves every viewer, which keeps you inside the connection cap of your plan.
  • Send `Cache-Control: no-cache, no-transform` on the event stream, or compression and proxies can buffer it.
  • Deploy on a long-running Node.js server; serverless functions cannot hold a shared socket open.

To use a Next.js WebSocket feed safely, open the WebSocket on the server, not in the browser. Market-data streams such as TickerLayer authenticate with the key in the connection URL (wss://stream.tickerlayer.com/?apiKey=...), so any page that opens it hands the key to every visitor. The pattern that works: one server-side connection in a Node.js process, a route handler that relays quotes as Server-Sent Events, and a small React hook that renders them.

The protocol itself (the ready frame, subscribe messages, snapshots, close codes) is covered in the stock WebSocket API guide. This tutorial builds the dashboard in six files, all App Router and TypeScript: a watchlist, a market hub, a route handler, a client component, the page and an environment file.

  1. TickerLayer streamwss, key in URL
  2. Market hubone socket per server
  3. Route handler/api/quotes/stream
  4. EventSourcetext/event-stream
  5. React tableuseQuotes()
The key stops at the market hub. Everything to its right is keyless.

Why a Next.js WebSocket belongs on the server

FeatureServer relay with SSEBrowser opens the streamBrowser polls your API
API key stays on the server
Updates as they happen
Reconnects without your codenot needed
Upstream cost with 100 viewers1 connection100 connectionsREST calls on every poll
Three ways to get live prices into a Next.js page.

Opening the stream in the browser fails twice. The key is readable in the network tab by anyone who loads the page, and each tab opens its own connection, while plans cap connections per feed (one on Individual, ten on Business). REST is no escape hatch either: browser REST calls are accepted only from approved origins, and polling burns quota. The authentication docs explain why the stream takes the key in the URL at all: browsers cannot set headers on a WebSocket upgrade.

Server-Sent Events fit the relay because the data only flows one way. They run over plain HTTP, the browser's EventSource reconnects by itself, and a route handler can return them as a streamed Response. Next.js route handlers cannot accept a WebSocket upgrade, so a browser-facing WebSocket would need a separate custom server anyway.

Project setup

  1. Create the appRun npx create-next-app@latest markets-dashboard and accept the defaults: TypeScript, App Router and the @/* import alias.
  2. Add the server-only guardRun npm install server-only. Importing it makes the build fail if a client component ever imports the hub.
  3. Store the keyPut TICKERLAYER_API_KEY=... in .env.local. No NEXT_PUBLIC_ prefix: that prefix copies a variable into the browser bundle.
  4. Use Node.js 22 or newerIt ships a built-in WebSocket client, so the server needs no extra package and nothing native to bundle.
Files
markets-dashboard/
  .env.local                      TICKERLAYER_API_KEY=... (never committed)
  lib/watchlist.ts                the instruments, shared by server and client
  lib/market-hub.ts               the only code that sees the key
  app/api/quotes/stream/route.ts  Server-Sent Events relay
  app/dashboard.tsx               client component with the useQuotes() hook
  app/page.tsx                    server component that renders the dashboard

The market hub: one upstream WebSocket per server

The watchlist is the security boundary as much as the key is. The browser never tells the server what to subscribe to, so nobody can use your connection to stream symbols you did not choose or exhaust your symbol cap. It is also the only file to change when you turn this into a pure stock dashboard: swap the rows for US: symbols on stocks.quotes.

lib/watchlist.tsJavaScript
// The only instruments the dashboard can show. The browser never decides
// what the server subscribes to with your key.
export const WATCHLIST = [
  { channel: "crypto.quotes", symbol: "BTCUSD", name: "Bitcoin" },
  { channel: "crypto.quotes", symbol: "ETHUSD", name: "Ether" },
  { channel: "forex.quotes", symbol: "EURUSD", name: "Euro / US dollar" },
  { channel: "forex.quotes", symbol: "USDJPY", name: "US dollar / yen" },
  { channel: "commodities.quotes", symbol: "XAUUSD", name: "Gold" },
  { channel: "stocks.quotes", symbol: "US:KO", name: "Coca-Cola" },
] as const;

export type Quote = { symbol: string; bid: number; ask: number; ts: number };
lib/market-hub.tsJavaScript
import "server-only";
import { WATCHLIST, type Quote } from "./watchlist";

type Listener = (quote: Quote) => void;

const STREAM_URL = "wss://stream.tickerlayer.com/?apiKey=";
const IDLE_CLOSE_MS = 60_000; // keep the socket a minute after the last viewer leaves

// One subscribe message per channel, built from the watchlist.
function subscribeMessages() {
  const byChannel = new Map<string, string[]>();
  for (const { channel, symbol } of WATCHLIST) {
    byChannel.set(channel, [...(byChannel.get(channel) ?? []), symbol]);
  }
  return [...byChannel].map(([channel, symbols]) => ({
    action: "subscribe",
    channels: [channel],
    symbols,
    snapshot: "always", // show the last known value even while a market is closed
  }));
}

class MarketHub {
  private ws: WebSocket | null = null;
  private listeners = new Set<Listener>();
  private latest = new Map<string, Quote>();
  private attempt = 0;
  private idleTimer: ReturnType<typeof setTimeout> | null = null;

  /** Register a viewer. Returns the function that unregisters it. */
  subscribe(listener: Listener): () => void {
    this.listeners.add(listener);
    if (this.idleTimer) clearTimeout(this.idleTimer);
    this.idleTimer = null;
    if (!this.ws) this.connect();
    return () => {
      this.listeners.delete(listener);
      if (this.listeners.size === 0 && !this.idleTimer) {
        this.idleTimer = setTimeout(() => this.ws?.close(1000), IDLE_CLOSE_MS);
      }
    };
  }

  /** Last known quote per symbol, for viewers who just arrived. */
  snapshot(): Quote[] {
    return [...this.latest.values()];
  }

  private connect() {
    const key = process.env.TICKERLAYER_API_KEY;
    if (!key) throw new Error("TICKERLAYER_API_KEY is not set");
    const ws = new WebSocket(STREAM_URL + encodeURIComponent(key));
    this.ws = ws;
    let openedAt = 0;

    ws.onopen = () => { openedAt = Date.now(); };
    ws.onmessage = (event) => {
      const msg = JSON.parse(String(event.data));
      if (msg.type === "system" && msg.event === "ready") {
        for (const sub of subscribeMessages()) ws.send(JSON.stringify(sub));
      } else if (msg.type === "quote") {
        // Prices arrive as strings on crypto, forex and stocks: normalise once, here.
        const quote: Quote = { symbol: msg.symbol, bid: Number(msg.bid), ask: Number(msg.ask), ts: Number(msg.ts) };
        this.latest.set(quote.symbol, quote);
        for (const listener of this.listeners) listener(quote);
      } else if (msg.type === "error") {
        console.error(`[market-hub] ${msg.code}: ${msg.message}`);
      }
    };
    ws.onerror = () => console.warn("[market-hub] socket error");
    ws.onclose = (event) => {
      this.ws = null;
      if (this.listeners.size === 0) return; // nobody is watching: stay closed
      if (openedAt && Date.now() - openedAt > 30_000) this.attempt = 0;
      const step = Math.min(30_000, 1_000 * 2 ** this.attempt++);
      const delay = event.code === 1012 ? Math.random() * 1_000 : step / 2 + Math.random() * (step / 2);
      console.warn(`[market-hub] closed with ${event.code}; reconnecting in ${Math.round(delay)} ms`);
      setTimeout(() => {
        if (!this.ws && this.listeners.size > 0) this.connect();
      }, delay);
    };
  }
}

// One hub per server process, even when dev mode reloads this module.
const globalForHub = globalThis as unknown as { marketHub?: MarketHub };
export const marketHub = (globalForHub.marketHub ??= new MarketHub());

Four decisions in this file carry most of the weight. The socket opens lazily, when the first viewer arrives, and closes a minute after the last one leaves, so an idle dashboard holds no connection. Prices are converted from strings to numbers once, here, so the browser receives clean JSON. The subscribe messages use snapshot: "always", which replays the last known value even for a closed market: at 03:00 UTC the US:KO row usually still shows a price, and the "As of" column makes its age obvious. And the instance lives on globalThis, so hot reloads in development do not open a new socket each time you save.

The reconnect logic is compact on purpose: jittered exponential backoff capped at 30 seconds, reset after a healthy connection, and a near-immediate return after close 1012 (a planned release). The WebSocket reconnect tutorial explains each branch, plus the heartbeat check you would add for a dashboard that runs for weeks.

The route handler: Server-Sent Events

app/api/quotes/stream/route.tsJavaScript
import { marketHub } from "@/lib/market-hub";
import type { Quote } from "@/lib/watchlist";

export const runtime = "nodejs"; // a long-lived socket needs Node.js, not the edge runtime

const FLUSH_MS = 250;        // at most four browser updates a second
const KEEP_ALIVE_MS = 15_000;

export async function GET(request: Request) {
  const encoder = new TextEncoder();
  let cleanup = () => {};

  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      const send = (chunk: string) => {
        try {
          controller.enqueue(encoder.encode(chunk));
        } catch {
          cleanup(); // the browser went away between two writes
        }
      };

      // Newest quote per symbol since the last flush; the browser never sees a backlog.
      const pending = new Map<string, Quote>();
      for (const quote of marketHub.snapshot()) pending.set(quote.symbol, quote);
      const unsubscribe = marketHub.subscribe((quote) => pending.set(quote.symbol, quote));

      const flush = setInterval(() => {
        if (pending.size === 0) return;
        send(`data: ${JSON.stringify([...pending.values()])}\n\n`);
        pending.clear();
      }, FLUSH_MS);
      const keepAlive = setInterval(() => send(": keep-alive\n\n"), KEEP_ALIVE_MS);

      cleanup = () => {
        clearInterval(flush);
        clearInterval(keepAlive);
        unsubscribe();
        cleanup = () => {};
      };
      request.signal.addEventListener("abort", () => {
        cleanup();
        try { controller.close(); } catch { /* already closed */ }
      });
    },
    cancel() {
      cleanup();
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform", // no-transform: do not let compression buffer it
      "X-Accel-Buffering": "no",                   // and tell nginx not to buffer it either
    },
  });
}

The handler returns a ReadableStream that never ends on its own. Each connected browser gets the hub's cached quotes first, then a flush every 250 ms carrying only the newest quote per symbol that changed. That coalescing is your choice, not a property of the feed: a busy symbol can change many times between two paints, and the browser only needs the latest. Comment lines every 15 seconds keep idle proxies from cutting the connection, and cleanup runs whether the browser disconnects (abort) or the stream is cancelled.

curl -N http://localhost:3000/api/quotes/stream (example, trimmed)
data: [{"symbol":"BTCUSD","bid":82849.99,"ask":82850,"ts":1790591203836},{"symbol":"EURUSD","bid":1.137107,"ask":1.137157,"ts":1790591203002},{"symbol":"XAUUSD","bid":4157.74375,"ask":4158.21375,"ts":1790591203896},{"symbol":"US:KO","bid":88.11,"ask":88.3,"ts":1790590887491}]

data: [{"symbol":"BTCUSD","bid":82849.99,"ask":82850,"ts":1790591204022}]

: keep-alive

A React hook for the live quotes

Most React WebSocket hooks open a socket in useEffect and close it in the cleanup. This one does the same with EventSource, which is simpler: no subscribe message, no heartbeat and no reconnect code, because the browser reconnects an event stream by itself.

app/dashboard.tsxJavaScript
"use client";

import { useEffect, useState } from "react";
import type { Quote } from "@/lib/watchlist";

type Row = { symbol: string; name: string };

function useQuotes() {
  const [quotes, setQuotes] = useState<Record<string, Quote>>({});
  const [live, setLive] = useState(false);

  useEffect(() => {
    const source = new EventSource("/api/quotes/stream");
    source.onopen = () => setLive(true);
    source.onerror = () => setLive(false); // EventSource reconnects on its own
    source.onmessage = (event) => {
      const batch: Quote[] = JSON.parse(event.data);
      setQuotes((previous) => {
        const next = { ...previous };
        for (const quote of batch) next[quote.symbol] = quote;
        return next;
      });
    };
    return () => source.close();
  }, []);

  return { quotes, live };
}

const price = (n: number) => {
  const digits = n >= 1_000 ? 2 : n >= 10 ? 3 : 5;
  return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
};
const utc = (ms: number) => new Date(ms).toISOString().slice(11, 19);
const right = { textAlign: "right" } as const;

export default function Dashboard({ rows }: { rows: readonly Row[] }) {
  const { quotes, live } = useQuotes();

  return (
    <section>
      <p aria-live="polite">{live ? "Live" : "Reconnecting..."}</p>
      <table>
        <thead>
          <tr>
            <th style={{ textAlign: "left" }}>Instrument</th>
            <th style={right}>Bid</th>
            <th style={right}>Ask</th>
            <th style={right}>Spread (bps)</th>
            <th style={right}>As of (UTC)</th>
          </tr>
        </thead>
        <tbody>
          {rows.map(({ symbol, name }) => {
            const q = quotes[symbol];
            const mid = q ? (q.bid + q.ask) / 2 : 0;
            return (
              <tr key={symbol}>
                <td>
                  <strong>{symbol}</strong> {name}
                </td>
                <td style={right}>{q ? price(q.bid) : "..."}</td>
                <td style={right}>{q ? price(q.ask) : "..."}</td>
                <td style={right}>{q ? (((q.ask - q.bid) / mid) * 10_000).toFixed(2) : "..."}</td>
                <td style={right}>{q ? utc(q.ts) : "..."}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </section>
  );
}
app/page.tsxJavaScript
import Dashboard from "./dashboard";
import { WATCHLIST } from "@/lib/watchlist";

export default function Page() {
  return (
    <main style={{ maxWidth: 760, margin: "48px auto", padding: "0 16px", fontFamily: "system-ui" }}>
      <h1>Markets</h1>
      <Dashboard rows={WATCHLIST.map(({ symbol, name }) => ({ symbol, name }))} />
    </main>
  );
}

Run npm run dev and open http://localhost:3000. Rows fill in as soon as the hub's first quotes arrive, then update in place. Restart the dev server and the status line switches to "Reconnecting..." and back, with no code of yours involved; if the upstream connection drops instead, the hub reconnects behind the scenes and the rows simply resume. The page itself renders without a single REST call: initial values come from the hub's cache over the same stream.

What one viewer costs

EventUpstream costWhy
First viewer opens the page1 WebSocket connection, 6 symbolsThe hub connects lazily and subscribes to the watchlist.
99 more viewers arriveNothing extra upstreamThey share the hub. Each costs one open HTTP response and a timer on your server.
A viewer reloadsNothingThe hub waits 60 seconds after the last viewer before closing.
Everyone leavesConnection closed after 60 sNo socket stays open while nobody watches.
A second server instance startsA second connectionEach process runs its own hub.
The relay turns viewer count into server load, not into upstream connections.

Six symbols across four feeds fit an Individual plan's limits, and limits of several feeds on one account combine; the WebSocket limits page has the details. Each channel needs its feed on your plan, so drop rows you do not have: a channel outside your packages answers INVALID_CHANNEL, which the hub logs.

Deploying it

  1. BrowserEventSource and React state. Sees prices, never the key.client
  2. Route handler/api/quotes/stream: one coalesced update per changed symbol every 250 ms.Node.js
  3. Market hubOne WebSocket per process, the watchlist, reconnects with backoff.server-only
  4. TickerLayer streamThe upstream WebSocket, authenticated with the key in its URL.upstream
Where each responsibility lives. The key never rises above the market hub.

The hub needs a process that stays alive: next build && next start on a VM, a container or any platform that runs a long-lived Node.js server. Serverless functions and the edge runtime are the wrong home. They end requests after a time limit, share no memory between invocations and may run many copies at once, each opening its own upstream connection.

Scaling out follows from the cost table: every instance is another upstream connection counted against your plan's connection cap. Beyond a few instances, run the hub as its own small service and fan out through a message bus such as Redis pub/sub, so the stream is still opened once. And a dashboard that other people use is usually commercial use: Individual plans are for personal and research use, while Business includes commercial use, as the pricing page states.

Before it goes live

  • The key is only in .env.local or your host's secret store, never under a NEXT_PUBLIC_ name.
  • lib/market-hub.ts imports server-only.
  • The watchlist is fixed on the server; the browser cannot add symbols.
  • The route handler runs on the Node.js runtime of a long-running server.
  • Proxies in front of the app do not buffer text/event-stream and allow idle connections longer than the keep-alive interval.
  • Logs never print the upstream URL, since it contains the key.
  • Prices are labelled as indicative market data, not official exchange prices.

From here, the same hub can feed a chart: the candlestick chart tutorial builds live candles from the stream, and the price alert bot tutorial sends the same quotes to chat instead of a table. More dashboard patterns are on the trading dashboards use case page.

Questions

Can Next.js route handlers handle WebSocket connections?

No. App Router route handlers answer HTTP requests and cannot accept a WebSocket upgrade. Stream to the browser with Server-Sent Events from a route handler, or run a separate WebSocket server next to Next.js.

How do I keep my API key out of the browser in Next.js?

Read it from a server-only environment variable without the NEXT_PUBLIC_ prefix, use it only in modules that import server-only, and send the browser the data rather than the connection.

Should I use Server-Sent Events or WebSockets for a dashboard?

For a dashboard that only receives data, Server-Sent Events are simpler: plain HTTP, automatic reconnects and no protocol upgrade. Use a WebSocket to the browser when the browser also has to send a stream of messages.

Can I deploy a Next.js WebSocket relay on serverless?

Not reliably. Serverless functions have execution time limits and no shared memory, so a single long-lived upstream connection cannot survive there; use a long-running Node.js server or container.

How do I use WebSockets in React?

Open the connection in useEffect, update state from its message handler and close it in the effect's cleanup so unmounting never leaves a socket behind. Keep the connection in a custom hook so components only see data.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.