3 Ways to Add Real-Time Features to Your Next.js App
David Lee

David Lee @i3dly

About: Enjoyer of Cloudflare Workers. Builder of typesafe real-time with pluv.io.

Location:
San Francisco
Joined:
Oct 20, 2021

3 Ways to Add Real-Time Features to Your Next.js App

Publish Date: Jun 16
0 1

Introduction

Building real-time apps in Next.js can be challenging. While the framework provides powerful full-stack primitives like API routes and server actions, it lacks native support for WebSockets. That’s likely because Vercel, the company behind Next.js, doesn’t yet support the WebSocket protocol on its platform.

So what are your options if you want real-time capabilities in your Next.js app? Let’s walk through a few.

What not to do

Before we dive into what our options are, let’s first address a common first instinct that you should actually avoid. I’ve seen way too many dev posts across the internet where people are defining an API request’s handler that routes into a WebSocket server like socket.io.

This is what I mean not to do:

// pages/api/ws.ts

import type { NextApiRequest, NextApiResponse } from "next";
import Http from "node:http";
import SocketIO from "socket.io";

let io: SocketIO.Server | null = null;

// ❌ What *not* to do
// This attempts to attach a Socket.IO server to a Next.js API route.
// It may *appear* to work in development, but will fail on most deployments.
export const handler = async (
  req: NextApiRequest,
  res: NextApiResponse
) => {
  if (!(res as any).socket) {
    res.status(500).end("No socket available");
    return;
  };

  if (!io) {
    const httpServer = (res as any).socket.server as Http.Server;
    const wsServer = new SocketIO.Server(httpServer, { path: "/api/ws" });

    wsServer.on("connection", (socket) => {
      socket.on("message", (message) => {
        socket.broadcast.emit("message", message);
      });
    });

    io = wsServer;
  }

  res.end();
};
Enter fullscreen mode Exit fullscreen mode

🛑 Avoid starting a WebSocket server in an API route. It may work locally but will break in serverless environments like Vercel.

Option 1: Long Polling

Good for: Lightweight real-time needs like activity feeds, background jobs, or infrequent updates.

Since you generally can’t use the WebSocket protocol with Next.js, the next easiest thing to do is just to long-poll with http. If you don’t need messages from the server to appear on the frontend quickly or frequently, you can simulate “updates from the server” by just having your frontend refetch on an interval to get the latest state of your data. TanStack Query is really nice for this.

"use client";

import { useQuery } from "@tanstack/react-query";

const REFETCH_INTERVAL_MS = 5_000; // 5 seconds

const { data, error, isFetching } = useQuery({
  queryFn: async () => {
    const res = await fetch("/api/feed", { /* ... */ });
    if (!res.ok || !res.status === 200) return null;

    return await res.json();
  },
  queryKey: ["getActivityFeed"],
  refetchInterval: REFETCH_INTERVAL_MS,
});
Enter fullscreen mode Exit fullscreen mode

This option is well-suited for building things like activity feeds, notifications, and long background tasks, where it’s acceptable for the frontend to be slightly delayed in reflecting changes to data (e.g. when someone you follow posts on LinkedIn, it’s fine to have it appear in your notifications 10 seconds later). However, this approach falls short when your real-time needs to be more instant, like when building real-time chats.

Option 2: Hosted WebSocket Server

Good for: Low-latency interactions like real-time chat, but requires infra.

When you need to support live-interactions between users, updates will need to be instantaneous, else the experience will feel clunky and broken. For this, having a separate WebSocket server outside of Next.js works very well. Since Vercel doesn’t support long-lived connections, you’ll need to deploy this WebSocket server separately, for example, on platforms like Fly.io, Railway, or your own VPS.

import { serve, type HttpBindings } from "@hono/node-server";
import { Hono } from "hono";
import Http from "node:http";
import SocketIO from "socket.io";

const PORT = 3_000;

// We're using Hono for example's sake and for simplicity
const app = new Hono<{ Bindings: HttpBindings }>();

const server = serve({ fetch: app.fetch, port: PORT }) as Http.Server;
const wsServer = new SocketIO.Server(server, { path: "/api/ws" });

wsServer.on(
  "connection",
  async (socket) => {
    socket.on("message", (message) => {
      socket.broadcast.emit("message", message);
    });
  }
);
Enter fullscreen mode Exit fullscreen mode
import { useEffect, useState, type FC } from "react";
import io from "socket.io-client";

export const Chat: FC = () => {
  const [message, setMessage] = useState<string>("");
  const [messages, setMessages] = useState<readonly string[]>([]);

  const [socket] = useState(() => {
    const ioClient = io("...");

    ioClient.on("message", (msg) => {
      setMessages((prev) => [...prev, msg]);
    });

    return ioClient;
  });

  useEffect(() => {
    return () => socket.disconnect();
  }, [socket]);

  const sendMessage = () => {
    socket.emit("message", message);
    setMessages((prev) => [...prev, message]);
    setMessage("");
  };

  return (
    <div>
      <ul>{messages.map((msg, i) => <li key={i}>{msg}</li>)}</ul>
      <input value={message} onChange={(e) => setMessage(e.target.value)} />
      <button onClick={sendMessage} type="button">Send</button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Check the socket.io docs for a more complete example of integrating with Next.js.

Option 3: Managed Real-time Service

Good for: Teams who want real-time without running their own WebSocket server.

But let’s say you want to avoid hosting a WebSocket server yourself. In that case, a fully-managed real-time platform can be a better fit. For this example, we will use pluv.io to showcase how we might build a real-time chat application.

Install Dependencies

pnpm install @pluv/io @pluv/platform-pluv @pluv/crdt-yjs yjs zod
pnpm install @pluv/client @pluv/react
Enter fullscreen mode Exit fullscreen mode

Setup Webhooks

Note that we will be using Yjs in this example to manage the real-time shared data.

// app/api/pluv/route.ts

import { db } from "@/database";
import { yjs } from "@pluv/crdt-yjs";
import { createIO } from "@pluv/io";
import { platformPluv } from "@pluv/platform-pluv";
import { z } from "zod";

const io = createIO(
  platformPluv({
    authorize: {
      user: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
    basePath: "/api/pluv",
    context: () => ({ db }),
    crdt: yjs,
    publicKey: process.env.PLUV_PUBLISHABLE_KEY!,
    secretKey: process.env.PLUV_SECRET_KEY!,
    webhookSecret: process.env.PLUV_WEBHOOK_SECRET!,
  })
);

export const ioServer = io.server({
  getInitialStorage: async ({ context, room }) => {
    const { db } = context;

    const rows = await db.sql<{ storage: string }[]>(
      "SELECT storage FROM room WHERE name = ?;",
      [room],
    );
    return rows[0]?.storage ?? null;
  },
  onRoomDeleted: async ({ context, encodedState, room }) => {
    const { db } = context;

    await db.sql(`
      INSERT INTO room(name, storage)
      VALUES(?, ?)
      ON CONFLICT(name) DO UPDATE
        SET storage = excluded.storage;
      `,
      [room, encodedState],
    );
  },
});

export const GET = ioServer.fetch;
export const POST = ioServer.fetch;
Enter fullscreen mode Exit fullscreen mode

Setup Room Authorization

// app/api/auth/route.ts

import { getSession } from "@/lib/auth";
import type { NextRequest } from "next/server";
import { ioServer } from "../pluv/route";

export const GET = async (request: NextRequest) => {
  const { user } = await getSession(request);
  const room = request.nextUrl.searchParams.room as string;

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const token = await ioServer.createToken({ room, user });

  return new Response(token);
};
Enter fullscreen mode Exit fullscreen mode

Setup Frontend

// lib/react-pluv.ts

import type { ioServer } from "@/app/api/pluv/route";
import { yjs } from "@pluv/crdt-yjs";
import { createClient, infer } from "@pluv/client";
import { createBundle } from "@pluv/react";

const types = infer((i) => ({ io: i<typeof ioServer> }));
const io = createClient({
  types,
  authEndpoint: ({ room }) => `/api/auth?room=${room}`,
  initialStorage: yjs.doc((t) => ({
    messages: t.array<string>("messages", []),
  })),
  publicKey: process.env.PLUV_PUBLISHABLE_KEY!,
});

export const {
  PluvRoomProvider,
  useStorage,
} = createBundle(io);
Enter fullscreen mode Exit fullscreen mode

Build Real-time Chat App

import { useStorage } from "@/lib/react-pluv";

const Page = () => {
  const [message, setMessage] = useState<string>("");
  const [messages, yArray] = useStorage();

  const sendMessage = () => {
    yArray?.push([message]);
    setMessage("");
  };

  return (
    <div>
      <ul>{messages?.map((msg, i) => <li key={i}>{msg}</li>)}</ul>
      <input value={message} onChange={(e) => setMessage(e.target.value)} />
      <button onClick={sendMessage} type="button">Send</button>
    </div>
  );
};

export default Page;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Real-time features can transform the user experience of a Next.js app, but they also introduce unique challenges, especially on serverless platforms. Depending on your needs, you might get by with simple polling, run a custom WebSocket server, or reach for a managed solution like pluv.io.

If you're building collaborative or interactive apps and want to skip the infrastructure grind, give pluv.io a try. It’s designed to plug into your Next.js stack with minimal setup, and unlocks powerful, type-safe building blocks for real-time apps.

Follow

pluv.io is open source and fully self-hostable. Check it out here: https://github.com/pluv-io/pluv

Follow me on Twitter, Bluesky, GitHub, or join the Discord for more updates!

Comments 1 total

  • Admin
    AdminJun 16, 2025

    Hey everyone! We’re launching DEV Contributor rewards for all verified Dev.to authors. Connect your wallet here to see if you qualify (limited supply — act fast). – Admin

Add comment