Skip to main content

How to Add Real-Time Features to Your Boilerplate 2026

·StarterPick Team
Share:

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.


When Real-Time Is Worth the Complexity

Real-time features add meaningful infrastructure complexity: you need a WebSocket service, authentication for private channels, client-side connection management, and event handling logic. Before adding this stack, verify the feature actually needs real-time versus polling.

Definitely real-time: Collaborative editing (multiple users in the same document), live cursor positions, instant messaging, multiplayer game state, live trading data.

Usually polling is fine: Dashboard metrics refreshing every 60 seconds, notification counts updating on page load, background job status checking every 30 seconds. Polling with useEffect + a 30-60 second interval is much simpler than WebSockets and sufficient for 90% of "keep data fresh" requirements.

The middle ground: New record notifications ("someone just commented on your post") work well with real-time but can also be solved with optimistic updates + polling. If your users work independently (not collaboratively), polling every 30 seconds often delivers acceptable UX with zero infrastructure cost.

The forcing function for real-time is collaborative features where latency above 500ms creates a noticeably bad experience: live document editing, presence indicators (who else is viewing this page), and chat.


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
}

Handling Real-Time in Server-Rendered Pages

The challenge with real-time in Next.js App Router: the initial page load is server-rendered (fast, SEO-friendly, no loading flicker), but real-time updates must happen on the client. The pattern that works cleanly:

Server Component renders the initial state → passes data to Client Component as props → Client Component subscribes to real-time events and updates local state. The initialTasks pattern is the standard:

// app/dashboard/tasks/page.tsx — Server Component
export default async function TasksPage() {
  const tasks = await getTasks(); // Server-side fetch

  return (
    // Pass server data to client component for real-time updates
    <TaskList initialTasks={tasks} orgId={session.organization.id} />
  );
}

This approach ensures the page renders instantly with server data, then "activates" for real-time updates when the client JavaScript loads. Users on slow connections or with JavaScript disabled see a fully functional read-only view. Users with JavaScript get live updates.

The key constraint: don't mix server and client rendering for the same data. If the task list is a Client Component subscribing to real-time events, don't also re-fetch it with revalidatePath on every server action — you'll get conflicting update paths.


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>
  );
}

Optimistic Updates with Real-Time Sync

The most polished real-time UX pattern is optimistic updates: the UI reflects the change immediately (before the server confirms), and real-time events from other users arrive and reconcile with the local state. This makes your product feel instant even on slow connections.

// components/TaskList.tsx — optimistic + real-time
'use client';
import { useState, useCallback, useOptimistic } from 'react';
import { useOrgChannel } from '@/hooks/useOrgChannel';
import { createTask } from '@/app/actions';

export function TaskList({ initialTasks, orgId }: Props) {
  const [tasks, setTasks] = useState(initialTasks);
  const [optimisticTasks, addOptimisticTask] = useOptimistic(
    tasks,
    (state, newTask: Task) => [newTask, ...state]
  );

  // Real-time events from OTHER users
  const handleEvent = useCallback((event: string, data: Task) => {
    if (event === 'task.created') {
      // Only add if we don't already have it (dedup with optimistic)
      setTasks(prev =>
        prev.some(t => t.id === data.id) ? prev : [data, ...prev]
      );
    }
  }, []);

  useOrgChannel(orgId, handleEvent);

  async function handleCreateTask(formData: FormData) {
    const title = formData.get('title') as string;
    const tempTask: Task = {
      id: `optimistic-${Date.now()}`,
      title,
      status: 'todo',
      userId: 'current-user',
    };

    // Show immediately in UI
    addOptimisticTask(tempTask);

    // Server action creates the real task and triggers Pusher event
    const realTask = await createTask({ title, orgId });

    // Replace optimistic with real
    setTasks(prev =>
      prev.map(t => t.id === tempTask.id ? realTask : t)
    );
  }

  return (
    <div>
      <form action={handleCreateTask}>
        <input name="title" placeholder="New task..." />
        <button type="submit">Add</button>
      </form>
      <ul>
        {optimisticTasks.map(task => (
          <li key={task.id} className={task.id.startsWith('optimistic') ? 'opacity-60' : ''}>
            {task.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

The useOptimistic hook (React 18+) handles the optimistic state — the item appears immediately with slightly reduced opacity, then snaps to the real data once the server responds. Real-time events from other users are deduplicated against the local state to prevent the same task appearing twice.


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.


Debugging Real-Time Issues in Production

WebSocket issues are harder to debug than HTTP issues because they're stateful and connection-dependent. The production debugging toolkit:

Pusher Debug Console: In the Pusher dashboard, the Debug Console shows every event published and every channel subscription in real time. When a customer reports "I'm not seeing live updates," open the Debug Console while they use the product to verify: (1) the server is publishing events, (2) the client is subscribing to the correct channel name, (3) authentication is succeeding.

Channel naming conventions: Channel name mismatches are the most common real-time bug. If the server publishes to private-org-abc123 but the client subscribes to private-organization-abc123, nothing arrives. Centralize channel names in a shared constant:

// lib/channels.ts — single source of truth for channel names
export const channels = {
  org: (orgId: string) => `private-org-${orgId}`,
  presence: (docId: string) => `presence-doc-${docId}`,
  user: (userId: string) => `private-user-${userId}`,
} as const;

// Use everywhere:
await publishEvent(channels.org(task.organizationId), 'task.created', data);
channel.subscribe(channels.org(orgId));

Event payload validation: When an event arrives on the client with unexpected shape, JavaScript silently ignores the properties that don't exist. Add a validation step to event handlers to catch schema mismatches between server and client:

const handleEvent = useCallback((event: string, data: unknown) => {
  if (event === 'task.created') {
    const parsed = taskEventSchema.safeParse(data);
    if (!parsed.success) {
      console.error('Invalid task.created event:', data, parsed.error);
      return;
    }
    setTasks(prev => [parsed.data, ...prev]);
  }
}, []);

Presence Channels: Who's Online

Pusher's presence channels extend private channels with member tracking — you can see who else is connected to the same channel. This enables "active users" indicators and collaborative cursor features.

// Presence channel auth (slightly different from private):
const authData = pusher.authorizeChannel(socketId, channelName, {
  user_id: session.user.id,
  user_info: {
    name: session.user.name,
    avatar: session.user.image,
  },
});
// hooks/usePresence.ts
'use client';
import { useEffect, useState } from 'react';
import { pusherClient } from '@/lib/pusher-client';

type Member = { id: string; info: { name: string; avatar: string } };

export function usePresence(channelName: string) {
  const [members, setMembers] = useState<Member[]>([]);

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

    channel.bind('pusher:subscription_succeeded', (data: { members: Record<string, unknown> }) => {
      setMembers(Object.entries(data.members).map(([id, info]) => ({ id, info: info as Member['info'] })));
    });

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

    channel.bind('pusher:member_removed', (member: Member) => {
      setMembers(prev => prev.filter(m => m.id !== member.id));
    });

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

  return members;
}

Render active users in a document header: usePresence('doc-abc123') returns an array of online users. Show their avatars in a stack. This single feature dramatically increases the perception that your product is collaborative.


Event Design Principles

The structure of your real-time events determines how maintainable the system is over time. Common mistakes:

Events that duplicate database state: Sending the entire object on every update (task.updated with all 20 fields of the Task model) bloats messages and creates stale data problems. Instead, send only what changed: { id, status, updatedAt } for a status change event.

Insufficient event granularity: A single data.changed event that clients must react to by re-fetching everything defeats the purpose of real-time. Design events at the action level: task.status_changed, comment.added, member.invited.

No event versioning: Your event structure will evolve as you add fields. Name events with specificity that gives you room to evolve: task.status_changed.v2 is ugly, but task.updated with a type: 'status_change' discriminant field gives you flexibility without breaking existing listeners.

Missing idempotency: Clients may receive the same event twice (network hiccups, reconnect). Design your event handlers to be idempotent: updating state to a specific value is idempotent, incrementing a counter is not.


Choosing Your Real-Time Service

The right service depends on scale, budget, and self-hosting comfort:

Pusher Channels is the safest choice for most early-stage SaaS. The free tier supports 200 concurrent connections and 200K messages per day — plenty for a product with under 500 active users. The client library is well-documented and the React integration is straightforward. The main drawback is cost at scale: $49/month for the Startup plan (500 connections) and $99/month for the Growth plan (2K connections).

Ably offers a more generous free tier (6M messages per month) and better global edge distribution, but the SDK is more complex and the pricing model (per-message rather than per-connection) can be harder to predict. Ably is the right choice if you expect bursty high-volume events (live sports data, auction bidding) rather than sustained concurrent connections.

Soketi is the budget option: it's Pusher-compatible (same client SDK, zero code changes) and costs only the infrastructure to run it — roughly $5-10/month on Fly.io or Railway. The tradeoff is operational overhead: you manage uptime, updates, and connection limits yourself. For solo founders who want to avoid vendor lock-in and are comfortable with a little DevOps, Soketi is compelling.

Partykit is the newest option in 2026 and takes a different architectural approach: instead of channels, you work with "parties" — durable objects that maintain state. The programming model is more powerful but also more opinionated. Best for applications that need collaborative state that persists server-side (collaborative documents, live whiteboards) rather than simple pub/sub event broadcasting.

The selection heuristic: start with Pusher free tier, switch to Soketi when you're past product-market fit and want to reduce infrastructure costs, and evaluate Ably or Partykit only if you have genuinely high message volume or need collaborative state management.


Connection State Management

WebSocket connections drop. Network changes, device sleep, and server restarts all cause disconnects. Your real-time implementation needs to handle reconnection gracefully:

// lib/pusher-client.ts — with connection state tracking
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',
  // Pusher client auto-reconnects by default with exponential backoff
});

// Track connection state
pusherClient.connection.bind('state_change', ({ previous, current }) => {
  if (current === 'connected') {
    console.log('Real-time connected');
    // Optionally: re-fetch data to catch events missed during disconnect
  }
  if (current === 'unavailable') {
    console.warn('Real-time unavailable — showing stale data');
    // Show a "Live updates paused" indicator in the UI
  }
});

The practical pattern for handling disconnects in a data list:

// components/TaskList.tsx — reconnect aware
export function TaskList({ initialTasks, orgId }: Props) {
  const [tasks, setTasks] = useState(initialTasks);
  const [isLive, setIsLive] = useState(true);

  // On reconnect, refresh data to catch missed events
  useEffect(() => {
    const handleReconnect = async () => {
      const freshTasks = await fetchTasks(orgId);
      setTasks(freshTasks);
      setIsLive(true);
    };

    pusherClient.connection.bind('connected', handleReconnect);
    pusherClient.connection.bind('disconnected', () => setIsLive(false));

    return () => {
      pusherClient.connection.unbind('connected', handleReconnect);
      pusherClient.connection.unbind('disconnected');
    };
  }, [orgId]);

  return (
    <div>
      {!isLive && (
        <div className="text-amber-600 text-sm mb-2">
          Live updates paused. Reconnecting...
        </div>
      )}
      <ul>{tasks.map(t => <li key={t.id}>{t.title}</li>)}</ul>
    </div>
  );
}

This pattern — refresh on reconnect, show an indicator during disconnect — prevents stale data from silently misleading users.


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
Presence channels1 hour
Testing with multiple tabs30 min
Total~5 hours

For boilerplates with real-time collaboration features pre-built, best boilerplates for real-time collaboration covers the tools that ship with presence, cursors, and live editing. For the team organization context required to scope channels per organization, team and org management guide covers the membership and permission layer. For AI streaming features (which use Server-Sent Events rather than WebSockets), how to add AI features to any SaaS boilerplate covers the Vercel AI SDK streaming patterns.


Methodology

Implementation patterns based on Pusher Channels documentation (2026), Soketi GitHub repository, and Ably documentation. Performance characteristics from Pusher's published benchmarks. Time estimates based on community reports in the Next.js Discord.

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

Check out this boilerplate

View ShipFaston StarterPick →

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.