How to Add Realtime to a Next.js App

Add live WebSocket features to your Next.js app — from server-side event publishing in Route Handlers to client-side subscriptions with a custom React hook.

Next.js is an excellent framework for building production applications, but its serverless-first architecture creates a fundamental mismatch with WebSockets. Vercel functions, AWS Lambda, and similar runtimes spin up on demand and terminate after a request completes — they cannot hold the long-lived TCP connections that WebSockets require. If you try to manage your own WebSocket server alongside a Next.js deployment, you end up maintaining a separate stateful service, dealing with sticky sessions, and handling reconnection logic yourself.

A managed WebSocket platform like Apinator solves this cleanly. Your Next.js Route Handlers stay stateless and simply publish events over HTTP. Apinator handles the persistent connections, channel routing, and delivery to every subscribed client. Your app gets realtime without any infrastructure burden.

Installing the SDKs

You need two packages: the server SDK for publishing events from Route Handlers, and the client SDK for subscribing in the browser.

npm install @apinator/server @apinator/sdk

Add your Apinator credentials to .env.local:

APINATOR_APP_ID=your_app_id
APINATOR_KEY=your_api_key
APINATOR_SECRET=your_api_secret
APINATOR_HOST=https://realtime.apinator.io

APINATOR_SECRET should never be exposed to the client. Keep it server-side only — the NEXT_PUBLIC_ prefix should never be used for it.

Publishing Events from a Route Handler

Route Handlers in the app/ directory are the right place to publish events. They run on the server, have access to environment variables, and can authenticate the caller before triggering anything.

Create app/api/events/route.ts:

import { Apinator } from "@apinator/server";
import { NextRequest, NextResponse } from "next/server";

const apinator = new Apinator({
  appId: process.env.APINATOR_APP_ID!,
  key: process.env.APINATOR_KEY!,
  secret: process.env.APINATOR_SECRET!,
  host: process.env.APINATOR_HOST!,
});

interface NotificationPayload {
  id: string;
  message: string;
  timestamp: string;
}

export async function POST(request: NextRequest) {
  // Authenticate the caller — replace with your own auth logic
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.INTERNAL_API_TOKEN}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json() as { userId: string; message: string };

  const payload: NotificationPayload = {
    id: crypto.randomUUID(),
    message: body.message,
    timestamp: new Date().toISOString(),
  };

  await apinator.trigger(`private-user.${body.userId}`, "notification", payload);

  return NextResponse.json({ ok: true });
}

The channel name private-user.${userId} uses Apinator's private- prefix, which means clients must authenticate before subscribing. This prevents one user from subscribing to another user's notifications.

Channel Auth Endpoint

Private channels require your server to sign subscription requests. When a client attempts to subscribe to a private- channel, the Apinator client SDK calls your auth endpoint automatically. Create app/api/auth/channel/route.ts:

import { Apinator } from "@apinator/server";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth"; // or your auth solution

const apinator = new Apinator({
  appId: process.env.APINATOR_APP_ID!,
  key: process.env.APINATOR_KEY!,
  secret: process.env.APINATOR_SECRET!,
  host: process.env.APINATOR_HOST!,
});

export async function POST(request: NextRequest) {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.formData();
  const socketId = body.get("socket_id") as string;
  const channelName = body.get("channel_name") as string;

  // Enforce that users can only subscribe to their own channel
  const expectedChannel = `private-user.${session.user.id}`;
  if (channelName !== expectedChannel) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const authResponse = apinator.authorizeChannel(socketId, channelName);

  return NextResponse.json(authResponse);
}

The authorizeChannel method produces the HMAC-signed response that Apinator's client SDK expects. The critical security check here is verifying that the requested channel name matches the authenticated user — without it, any logged-in user could subscribe to any private channel by guessing the name.

A Reusable useChannel Hook

Rather than scattering Apinator client setup across components, encapsulate connection and subscription logic in a hook. Create hooks/useChannel.ts:

"use client";

import { useEffect, useRef, useState } from "react";
import { RealtimeClient, type Channel } from "@apinator/sdk";

interface UseChannelOptions<T> {
  channelName: string;
  eventName: string;
  onEvent: (data: T) => void;
}

let client: RealtimeClient | null = null;

function getClient(): RealtimeClient {
  if (!client) {
    client = new RealtimeClient(process.env.NEXT_PUBLIC_APINATOR_KEY!, {
      authEndpoint: "/api/auth/channel",
    });
  }
  return client;
}

export function useChannel<T>({
  channelName,
  eventName,
  onEvent,
}: UseChannelOptions<T>): { connected: boolean } {
  const [connected, setConnected] = useState(false);
  const channelRef = useRef<Channel | null>(null);
  const onEventRef = useRef(onEvent);

  // Keep the callback ref up to date without re-subscribing
  useEffect(() => {
    onEventRef.current = onEvent;
  }, [onEvent]);

  useEffect(() => {
    const apinator = getClient();

    const handleConnected = () => setConnected(true);
    const handleDisconnected = () => setConnected(false);

    apinator.connection.on("connected", handleConnected);
    apinator.connection.on("disconnected", handleDisconnected);

    channelRef.current = apinator.subscribe(channelName);

    channelRef.current.bind(eventName, (data: T) => {
      onEventRef.current(data);
    });

    return () => {
      channelRef.current?.unbind(eventName);
      apinator.unsubscribe(channelName);
      apinator.connection.off("connected", handleConnected);
      apinator.connection.off("disconnected", handleDisconnected);
    };
  }, [channelName, eventName]);

  return { connected };
}

The module-level client singleton ensures a single WebSocket connection is shared across all components in the page, regardless of how many times useChannel is called. The cleanup function in the useEffect unbinds the event handler and unsubscribes from the channel when the component unmounts.

Using the Hook: Live Notifications

With the hook in place, adding live notifications to any client component is straightforward. Note the "use client" directive — this is required because the hook uses useEffect and browser APIs. You cannot subscribe to channels in a Server Component.

"use client";

import { useState, useCallback } from "react";
import { useChannel } from "@/hooks/useChannel";

interface Notification {
  id: string;
  message: string;
  timestamp: string;
}

interface NotificationBellProps {
  userId: string;
}

export function NotificationBell({ userId }: NotificationBellProps) {
  const [notifications, setNotifications] = useState<Notification[]>([]);

  const handleNotification = useCallback((data: Notification) => {
    setNotifications((prev) => [data, ...prev].slice(0, 20));
  }, []);

  const { connected } = useChannel<Notification>({
    channelName: `private-user.${userId}`,
    eventName: "notification",
    onEvent: handleNotification,
  });

  return (
    <div className="relative">
      <button aria-label="Notifications">
        {notifications.length > 0 && (
          <span className="badge">{notifications.length}</span>
        )}
      </button>

      {!connected && (
        <p className="text-sm text-muted">Connecting...</p>
      )}

      <ul>
        {notifications.map((n) => (
          <li key={n.id}>
            <p>{n.message}</p>
            <time dateTime={n.timestamp}>
              {new Date(n.timestamp).toLocaleTimeString()}
            </time>
          </li>
        ))}
      </ul>
    </div>
  );
}

The userId prop comes from the parent, which can be a Server Component that fetches the session. Pass it down to NotificationBell as a plain string — the server component fetches data, the client component handles the subscription.

// app/dashboard/page.tsx — Server Component
import { getServerSession } from "next-auth";
import { NotificationBell } from "@/components/NotificationBell";

export default async function DashboardPage() {
  const session = await getServerSession();

  return (
    <main>
      <NotificationBell userId={session!.user.id} />
      {/* rest of dashboard */}
    </main>
  );
}

Server Component vs Client Component

The boundary matters: data fetching and authentication belong in Server Components, while anything that touches the WebSocket connection must be a Client Component. A common mistake is trying to call useChannel at the page level in an async server component — that will throw a build error.

The pattern above keeps the boundary clean. Server Components own session data and pass primitive values (strings, numbers) as props to Client Components that own the subscriptions.

Triggering Events in Practice

To test the full flow, POST to your Route Handler from anywhere — a cron job, another API endpoint, or a webhook from an external service:

await fetch("https://your-app.vercel.app/api/events", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
  },
  body: JSON.stringify({
    userId: "user_123",
    message: "Your report is ready to download.",
  }),
});

Any browser tab subscribed to private-user.user_123 will receive the notification immediately, without polling.

What You've Built

With under a hundred lines of application code, you have end-to-end realtime notifications in a Next.js app: a server-side publisher that authenticates callers, a channel auth endpoint that enforces per-user access, a reusable hook that manages the WebSocket lifecycle, and a client component that renders incoming events. Apinator handles the connection state, reconnection backoff, and fanout across every subscribed client — your Next.js functions stay stateless and your deployment stays simple.