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
| Service | Free Tier | Cost | Self-Host |
|---|---|---|---|
| Pusher | 200 connections, 200K msg/day | $49/mo (Startup) | ❌ |
| Ably | 6M messages/mo | $25/mo | ❌ |
| Soketi | Unlimited (self-host) | ~$5/mo on Fly.io | ✅ |
| Partykit | Generous | $25/mo | ❌ |
For most SaaS apps under 200 concurrent users, Pusher's free tier is sufficient to start.
Time Budget
| Task | Duration |
|---|---|
| Pusher setup + auth endpoint | 1 hour |
useOrgChannel hook | 30 min |
| Toast notifications | 30 min |
| Real-time table updates | 1 hour |
| Testing with multiple tabs | 30 min |
| Total | ~4 hours |
Find boilerplates with real-time features built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →