DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Real-Time Features in Next.js 15 The Pattern I Use for Live Updates

A queue status that only updates on page refresh is not actually useful for a live dashboard. Neither is a chat that requires the user to manually reload to see a new message. Real-time features are where Server Components stop being enough on their own, since they render once per request and have no way to push new data down after the fact.

Here is the setup I use when a project genuinely needs live updates, not just fast page loads.


1. Why Not Just Poll

Polling, hitting an API every few seconds to check for changes, works and is simple to build. It also means every connected client hits your server on a timer regardless of whether anything actually changed, which adds real load as the number of users grows.

Pusher (or a similar service like Ably) uses WebSockets under the hood, so updates push to clients the moment something changes, no polling interval, no wasted requests when nothing is new.


2. The Setup

npm install pusher pusher-js
Enter fullscreen mode Exit fullscreen mode
// lib/pusher-server.ts
import Pusher from 'pusher';

export const pusherServer = new Pusher({
  appId: process.env.PUSHER_APP_ID as string,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY as string,
  secret: process.env.PUSHER_SECRET as string,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER as string,
  useTLS: true,
});
Enter fullscreen mode Exit fullscreen mode
// lib/pusher-client.ts
'use client';
import PusherClient from 'pusher-js';

export const pusherClient = new PusherClient(
  process.env.NEXT_PUBLIC_PUSHER_KEY as string,
  { cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER as string }
);
Enter fullscreen mode Exit fullscreen mode

Two separate clients, since the server client can trigger events with the secret key, and the browser client only ever subscribes and listens, never authenticates with anything sensitive.


3. Triggering an Event from the Server

Real-time updates start from a normal Server Action or webhook, exactly like the rest of your backend logic, just with one extra call at the end.

// actions/queue.ts
'use server';
import { pusherServer } from '@/lib/pusher-server';
import { connectDB } from '@/lib/db';
import QueueEntry from '@/models/QueueEntry';

export async function callNextPatient(clinicId: string) {
  await connectDB();

  const next = await QueueEntry.findOneAndUpdate(
    { clinicId, status: 'waiting' },
    { status: 'called' },
    { sort: { position: 1 }, new: true }
  );

  if (next) {
    await pusherServer.trigger(`clinic-${clinicId}`, 'patient-called', {
      patientId: next._id,
      name: next.patientName,
    });
  }

  return next;
}
Enter fullscreen mode Exit fullscreen mode

The database update happens first, and stays the source of truth. The Pusher event is a notification that something changed, not the change itself, which matters if a client's connection drops and they need to refetch the real state later.


4. Subscribing on the Client

// components/QueueDisplay.tsx
'use client';
import { useEffect, useState } from 'react';
import { pusherClient } from '@/lib/pusher-client';

interface QueueDisplayProps {
  clinicId: string;
  initialQueue: QueueEntry[];
}

export function QueueDisplay({ clinicId, initialQueue }: QueueDisplayProps) {
  const [queue, setQueue] = useState(initialQueue);
  const [calledPatient, setCalledPatient] = useState<string | null>(null);

  useEffect(() => {
    const channel = pusherClient.subscribe(`clinic-${clinicId}`);

    channel.bind('patient-called', (data: { patientId: string; name: string }) => {
      setCalledPatient(data.name);
      setQueue((prev) => prev.filter((entry) => entry._id !== data.patientId));
    });

    return () => {
      pusherClient.unsubscribe(`clinic-${clinicId}`);
    };
  }, [clinicId]);

  return (
    <div>
      {calledPatient && <p className="text-lg font-semibold">Now calling: {calledPatient}</p>}
      <ul>
        {queue.map((entry) => (
          <li key={entry._id}>{entry.patientName}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

initialQueue comes from the server on first render, a normal Server Component fetch. Pusher only handles what changes after that, so the page is never empty on load waiting for a WebSocket connection to establish.


5. Private Channels for Sensitive Data

Public channels work for something like a public queue display, but anything user-specific needs authentication, so a random client cannot subscribe to another user's private channel just by knowing its name.

// app/api/pusher/auth/route.ts
import { pusherServer } from '@/lib/pusher-server';
import { getSession } from '@/lib/auth';

export async function POST(request: Request) {
  const session = await getSession();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const formData = await request.formData();
  const socketId = formData.get('socket_id') as string;
  const channel = formData.get('channel_name') as string;

  // Only allow subscribing to your own private channel
  if (channel !== `private-user-${session.userId}`) {
    return new Response('Forbidden', { status: 403 });
  }

  const authResponse = pusherServer.authorizeChannel(socketId, channel);
  return Response.json(authResponse);
}
Enter fullscreen mode Exit fullscreen mode
'use client';
import PusherClient from 'pusher-js';

export const pusherClient = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY as string, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER as string,
  authEndpoint: '/api/pusher/auth',
});
Enter fullscreen mode Exit fullscreen mode

The channel name check inside the auth route is the actual security boundary here. Without it, any authenticated user could subscribe to any other user's private channel just by guessing the naming pattern.


6. Presence Channels for "Who's Online"

For showing which users are currently active, presence channels track who's subscribed automatically:

// components/OnlineUsers.tsx
'use client';
import { useEffect, useState } from 'react';
import { pusherClient } from '@/lib/pusher-client';

export function OnlineUsers({ roomId }: { roomId: string }) {
  const [members, setMembers] = useState<string[]>([]);

  useEffect(() => {
    const channel = pusherClient.subscribe(`presence-room-${roomId}`);

    channel.bind('pusher:subscription_succeeded', (data: any) => {
      setMembers(Object.values(data.members).map((m: any) => m.name));
    });

    channel.bind('pusher:member_added', (member: any) => {
      setMembers((prev) => [...prev, member.info.name]);
    });

    channel.bind('pusher:member_removed', (member: any) => {
      setMembers((prev) => prev.filter((name) => name !== member.info.name));
    });

    return () => {
      pusherClient.unsubscribe(`presence-room-${roomId}`);
    };
  }, [roomId]);

  return <p>{members.length} online: {members.join(', ')}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Presence channels need the same auth endpoint as private channels, just with member info attached to the auth response, so Pusher knows who to report as joined or left.


7. Reconnection Handling

Pusher reconnects automatically after a dropped connection, but the data it missed while disconnected is gone unless you account for it. On reconnect, refetch the real state from the server rather than trusting that every event was received:

useEffect(() => {
  pusherClient.connection.bind('connected', () => {
    // refetch current state in case events were missed while disconnected
    refetchQueue();
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

This is the same principle as treating the database as the source of truth from earlier. Real-time events are a fast path for updates, not a guaranteed delivery system, so anything that actually matters should be recoverable from a fresh fetch, not solely dependent on every event arriving.


Summary

Pattern Handles
Pusher trigger after a database write Notifying clients without polling
Database as source of truth Recoverable state if an event is missed
Public channels Non-sensitive shared data, like a public queue display
Private channels + auth endpoint User-specific data, scoped by channel name check
Presence channels Tracking who is currently active in a room or page
Reconnect handling Refetching real state instead of trusting every event arrived

The mental model that made this click: real-time events are a notification layer sitting on top of your normal database and Server Action logic, not a replacement for it. The database write happens the same way it always would. Pusher's job is just telling connected clients that something changed.

I use this exact pattern, Pusher triggered from Server Actions, private channels for user-specific data, for live queue and dashboard updates in the SaaS projects I build.

Get the templates: https://pixelanas.gumroad.com

Do you reach for WebSockets/Pusher, or handle "live" updates with polling on most projects? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)