Skip to main content

How to Add Real-Time Features (WebSockets) to Your Boilerplate (2026)

·StarterPick Team
websocketsreal-timepushernextjsguide2026

TL;DR

WebSockets in Next.js means using a hosted real-time service. Next.js serverless functions don't support persistent WebSocket connections. Use Pusher Channels, Ably, or Soketi (self-hosted open-source Pusher) instead. Pusher's free tier (200 connections, 200K messages/day) covers most early-stage SaaS. Setup time: 2-4 hours.


Option 1: Pusher Channels (Easiest)

npm install pusher pusher-js

Server: Publish Events

// lib/pusher.ts
import Pusher from 'pusher';

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.PUSHER_CLUSTER!,
  useTLS: true,
});

// Publish an event from any server-side code
export async function publishEvent(
  channel: string,
  event: string,
  data: unknown
) {
  await pusher.trigger(channel, event, data);
}
// Example: publish after a database write
// app/api/tasks/route.ts
export async function POST(req: Request) {
  const task = await prisma.task.create({ data: await req.json() });

  // Notify all members of the organization
  await publishEvent(
    `private-org-${task.organizationId}`,
    'task.created',
    { id: task.id, title: task.title, createdBy: task.userId }
  );

  return Response.json(task);
}

Authentication Endpoint (Private Channels)

// app/api/pusher/auth/route.ts
import { getServerSession } from 'next-auth';
import { pusher } from '@/lib/pusher';

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

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

  // Verify user has access to this channel
  const orgId = channelName.replace('private-org-', '');
  const membership = await prisma.organizationMember.findFirst({
    where: { userId: session.user.id, organizationId: orgId },
  });

  if (!membership) return new Response('Forbidden', { status: 403 });

  const authData = pusher.authorizeChannel(socketId, channelName, {
    user_id: session.user.id,
    user_info: { name: session.user.name },
  });

  return Response.json(authData);
}

Client: Subscribe to Events

// lib/pusher-client.ts
import PusherClient from 'pusher-js';

export const pusherClient = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  authEndpoint: '/api/pusher/auth',
  auth: {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  },
});
// hooks/useOrgChannel.ts
'use client';
import { useEffect } from 'react';
import { pusherClient } from '@/lib/pusher-client';

export function useOrgChannel(
  orgId: string,
  onEvent: (event: string, data: unknown) => void
) {
  useEffect(() => {
    const channel = pusherClient.subscribe(`private-org-${orgId}`);

    channel.bind_global((event: string, data: unknown) => {
      if (!event.startsWith('pusher:')) { // Skip Pusher system events
        onEvent(event, data);
      }
    });

    return () => {
      pusherClient.unsubscribe(`private-org-${orgId}`);
    };
  }, [orgId, onEvent]);
}

Real-Time Notifications

// components/NotificationsToast.tsx
'use client';
import { useOrgChannel } from '@/hooks/useOrgChannel';
import { toast } from 'sonner';
import { useCallback } from 'react';

export function NotificationsToast({ orgId }: { orgId: string }) {
  const handleEvent = useCallback((event: string, data: { title: string; message?: string }) => {
    switch (event) {
      case 'task.created':
        toast.info(`New task: ${data.title}`);
        break;
      case 'member.joined':
        toast.success(`${data.title} joined the organization`);
        break;
      case 'invoice.paid':
        toast.success(`Payment received: ${data.title}`);
        break;
    }
  }, []);

  useOrgChannel(orgId, handleEvent);

  return null; // No UI — toasts appear globally
}

Real-Time Table Updates

// components/TaskList.tsx
'use client';
import { useState, useCallback } from 'react';
import { useOrgChannel } from '@/hooks/useOrgChannel';

type Task = { id: string; title: string; status: string; userId: string };

export function TaskList({
  initialTasks,
  orgId,
}: {
  initialTasks: Task[];
  orgId: string;
}) {
  const [tasks, setTasks] = useState(initialTasks);

  const handleEvent = useCallback((event: string, data: Task) => {
    if (event === 'task.created') {
      setTasks(prev => [data, ...prev]);
    }
    if (event === 'task.updated') {
      setTasks(prev => prev.map(t => t.id === data.id ? data : t));
    }
    if (event === 'task.deleted') {
      setTasks(prev => prev.filter(t => t.id !== data.id));
    }
  }, []);

  useOrgChannel(orgId, handleEvent);

  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>{task.title}</li>
      ))}
    </ul>
  );
}

Option 2: Soketi (Self-Hosted, Free)

Soketi is a Pusher-compatible server you host yourself. Use the same Pusher client SDK:

# Docker
docker run -p 6001:6001 quay.io/soketi/soketi

# Or fly.io
fly deploy --image quay.io/soketi/soketi
# .env — point to your Soketi instance instead of Pusher
PUSHER_APP_ID=app-id
PUSHER_KEY=app-key
PUSHER_SECRET=app-secret
PUSHER_CLUSTER=mt1
PUSHER_HOST=your-soketi.fly.dev
PUSHER_PORT=443
PUSHER_USE_TLS=true

The client code stays identical — just change the host in the Pusher config.


Service Comparison

ServiceFree TierCostSelf-Host
Pusher200 connections, 200K msg/day$49/mo (Startup)
Ably6M messages/mo$25/mo
SoketiUnlimited (self-host)~$5/mo on Fly.io
PartykitGenerous$25/mo

For most SaaS apps under 200 concurrent users, Pusher's free tier is sufficient to start.


Time Budget

TaskDuration
Pusher setup + auth endpoint1 hour
useOrgChannel hook30 min
Toast notifications30 min
Real-time table updates1 hour
Testing with multiple tabs30 min
Total~4 hours

Find boilerplates with real-time features built-in on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments