Real-time WebSockets in Next.js: Roll-your-own, Cloudflare, or Vask
You have a Next.js app and you want real-time. A live notification counter. A collaborative presence indicator. A live dashboard that does not need a page refresh. A typing indicator in a chat surface.
Here is the first thing to know: Next.js does not ship a first-party WebSocket server. Unlike Laravel (which ships Reverb) or Rails (which ships ActionCable), Next.js is optimized for request/response. API routes and route handlers terminate after sending a response. Persistent WebSocket connections are out of scope for the framework by design.
This is not a criticism. It is a constraint that shapes your options, and the options are real and well-understood. This page walks through the three that matter in 2026.
Get started
Realtime made simple.
Free Tier: 500K broadcasts/mo and 100 concurrent across unlimited apps. $10/mo when you outgrow it.
Option 1: Roll-your-own WebSocket server
The first option is to run a WebSocket server yourself, separate from Next.js. Common approaches: a Node.js process using the ws package, a custom server that replaces Next.js's built-in HTTP server, or a standalone microservice alongside your Next.js deployment.
What it gives you:
- Full control. Any wire protocol, any auth model, any persistence layer.
- No per-message billing. The cost is your server.
- Works on any hosting platform that lets you run a persistent Node.js process.
The real constraints:
Running a persistent WebSocket server is an ops commitment. You need process supervision, a restart strategy, and a deploy story that does not drop active connections. On serverless platforms (Vercel, Netlify), you cannot run a long-lived WebSocket process at all: functions terminate after the response. You would need to deploy the WebSocket server separately on a platform that supports persistent processes.
On Vercel, do not plan to host a durable broadcast WebSocket server inside Functions. Use a separate service for the persistent socket layer, then publish to it from Next.js route handlers or API routes.
Official docs: Vercel's WebSocket guidance.
When it is the right call:
- You have a custom protocol requirement that no hosted service supports.
- You are already running your own infrastructure and the ops burden is already accounted for.
- Your team has the capacity to own the WebSocket process long-term.
If none of those apply, the hosted options below are the more reliable path for most Next.js projects.
Option 2: Cloudflare Durable Objects / Workers
Cloudflare's Durable Objects give you stateful, globally distributed WebSocket support without running your own server. A Durable Object is a single-threaded actor with persistent storage; each one can hold many WebSocket connections and fan messages out to all of them.
What it gives you:
- Stateful WebSocket rooms at the edge, with the persistence and consistency guarantees Durable Objects provide.
- Cloudflare's 330+ edge cities as the substrate. Connections terminate at the closest city to the user.
- No long-lived process to supervise. The runtime manages lifecycle.
The real constraints:
The programming model is different. Durable Objects are not a drop-in for the Pusher Channels pattern; you are building the routing and auth layer yourself, in Workers code. There is no pusher-js client that speaks the Durable Objects protocol natively. You are building the real-time infrastructure, not consuming it.
The trade-off is flexibility versus integration cost. If you want the Pusher Channels protocol (channel subscriptions, private channels, presence channels, channel auth) without building it from scratch, the Durable Objects path requires significant glue code. The full picture for the Cloudflare architecture is at /alternatives/cloudflare-websockets.
When it is the right call:
- You want edge-native WebSocket infrastructure with full control over the room model.
- You are building on the Cloudflare stack already and want to avoid a separate vendor.
- Your use case does not map to the Pusher Channels protocol and you need custom routing.
Option 3: Vask (hosted Pusher protocol on Cloudflare's edge)
Vask is the option that collapses the integration cost of Option 2 while keeping the edge delivery. A managed Pusher-protocol WebSocket service running on Cloudflare's edge, billed per broadcast (not per delivered message), with connections terminating at the closest of 330+ edge cities.
What it gives you:
- The Pusher Channels protocol on the wire, kept on purpose. Your pusher-js client, your channel subscriptions, your private and presence channel naming, your channel auth endpoint: all standard, all documented, all done.
- Cloudflare's edge as the substrate. Same edge network as Option 2, without the Durable Objects glue code.
- Broadcast-priced billing. One broadcast to a channel is one broadcast on the bill, regardless of how many subscribers are on it. No fan-out tax. No presence-channel surcharge.
- Drop-in compatibility with any existing Pusher-protocol integration. If you are already on Pusher Channels, the cutover is a credentials change in
.env.local, not a code change.
When it is the right call:
- You want channels, private channels, and presence in a Next.js app without building the infrastructure layer yourself.
- You are on hosted Pusher today, the bill is growing with fan-out, and you want the same protocol with broadcast-priced billing.
- You want multi-region edge presence without an ops commitment.
If you are migrating from hosted Pusher specifically, the step-by-step Next.js recipe is at /migrate/pusher-to-vask-nextjs. The head-to-head comparison with calculator is at /compare/pusher-vs-vask.
What the Next.js code actually looks like
The Pusher-protocol integration is the same regardless of whether the service is Pusher or Vask. The differences are credentials in .env.local.
Environment variables
.env.local:
NEXT_PUBLIC_PUSHER_APP_KEY=your_vask_key
NEXT_PUBLIC_PUSHER_HOST=wss.vask.dev
NEXT_PUBLIC_PUSHER_PORT=443
NEXT_PUBLIC_PUSHER_APP_CLUSTER=mt1
# Server-side only (no NEXT_PUBLIC_ prefix)
PUSHER_APP_ID=your_vask_key
PUSHER_APP_KEY=your_vask_key
PUSHER_APP_SECRET=your_vask_secret
PUSHER_HOST=wss.vask.dev
PUSHER_APP_CLUSTER=mt1NEXT_PUBLIC_* variables are inlined at build time and safe to expose to the browser. The secret must not have the NEXT_PUBLIC_ prefix. Vask gives you a key and secret; if an SDK requires appId, use the Vask key for that field. mt1 is only a compatibility placeholder for SDKs that expect a cluster value.
Singleton client (Pages Router and App Router)
Hot-reload re-creates modules on every save. Without a singleton guard, each save creates a new Pusher connection, which exhausts your connection limit quickly in development.
lib/pusher-client.ts:
import Pusher from 'pusher-js';
let pusherInstance: Pusher | null = null;
export function getPusherClient(): Pusher {
if (pusherInstance) {
return pusherInstance;
}
pusherInstance = new Pusher(process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, {
wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST,
wsPort: Number(process.env.NEXT_PUBLIC_PUSHER_PORT ?? 443),
forceTLS: true,
enabledTransports: ['ws', 'wss'],
cluster: process.env.NEXT_PUBLIC_PUSHER_APP_CLUSTER ?? 'mt1',
channelAuthorization: {
endpoint: '/api/pusher/auth',
transport: 'ajax',
},
});
return pusherInstance;
}The module-level pusherInstance variable persists across hot-reloads in development and across component mounts/unmounts in production.
Client subscription (App Router with 'use client')
app/components/presence-indicator.tsx:
'use client';
import { useEffect, useState } from 'react';
import { getPusherClient } from '@/lib/pusher-client';
type Member = { id: string; name: string };
export function PresenceIndicator({ documentId }: { documentId: string }) {
const [members, setMembers] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
const pusher = getPusherClient();
const channel = pusher.subscribe(`presence-document.${documentId}`);
channel.bind(
'pusher:subscription_succeeded',
(data: { members: Record<string, Member> }) => {
setMembers(Object.values(data.members));
},
);
channel.bind(
'pusher:member_added',
(member: { id: string; info: Member }) => {
setMembers((prev) => [...prev, member.info]);
},
);
channel.bind('pusher:member_removed', (member: { id: string }) => {
setMembers((prev) => prev.filter((m) => m.id !== member.id));
});
return () => {
pusher.unsubscribe(`presence-document.${documentId}`);
};
}, [documentId]);
return <div>{members.length} online</div>;
}The 'use client' directive is required. Server components cannot hold WebSocket connections; the Pusher client must initialize in a client component. The singleton from getPusherClient() prevents a new connection on every render.
Channel auth endpoint (App Router route handler)
app/api/pusher/auth/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import Pusher from 'pusher';
import { auth } from '@/lib/auth';
const pusherServer = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_APP_KEY!,
secret: process.env.PUSHER_APP_SECRET!,
host: process.env.PUSHER_HOST!,
useTLS: true,
});
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.text();
const params = new URLSearchParams(body);
const socketId = params.get('socket_id')!;
const channel = params.get('channel_name')!;
const presenceData = {
user_id: session.user.id,
user_info: {
name: session.user.name,
},
};
const authResponse = pusherServer.authorizeChannel(
socketId,
channel,
presenceData,
);
return NextResponse.json(authResponse);
}For Pages Router, the equivalent lives at pages/api/pusher/auth.ts and uses req.body instead of request.text(). The channel auth logic is the same; only the handler signature differs.
Publishing a broadcast (App Router route handler)
app/api/events/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import Pusher from 'pusher';
const pusherServer = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_APP_KEY!,
secret: process.env.PUSHER_APP_SECRET!,
host: process.env.PUSHER_HOST!,
useTLS: true,
});
export async function POST(request: NextRequest) {
const { channel, event, data } = await request.json();
await pusherServer.trigger(channel, event, data);
return NextResponse.json({ ok: true });
}The pusher package is the official Pusher HTTP library for Node.js. It speaks to any Pusher-protocol service, including Vask. The same trigger() call works whether PUSHER_HOST points at Pusher's clusters or Vask's edge.
Picking between the three
Do you have a Next.js app?
|
YES
|
Do you need the Pusher Channels protocol
(channels, presence, channel auth)
without building it yourself?
|
┌─────────────┴─────────────┐
YES NO
| |
Is hosted infra acceptable? Cloudflare DOs/Workers
| or roll-your-own.
┌─────┴─────┐
YES NO
| |
VASK. Roll-your-own
Done. WebSocket server.Vask is the right answer when you want the Pusher protocol handled for you, on Cloudflare's edge, without operating infrastructure. If you need full control of the WebSocket room model, Cloudflare Durable Objects are the right primitive. If neither, a standalone WebSocket server is the escape hatch.
- Does Next.js have a built-in WebSocket server?
- No. Next.js does not ship a first-party WebSocket server the way Laravel ships Reverb or Rails ships ActionCable. The framework is optimized for request/response: API routes and route handlers terminate after sending a response. Persistent WebSocket connections need to live outside the Next.js request cycle, either as a separate Node process you manage yourself, on a serverless edge runtime that supports WebSockets like Cloudflare Workers, or on a hosted service like Vask that speaks the Pusher Channels protocol.
- Can I host WebSockets inside a Vercel deployment?
- Not as a durable WebSocket server. Vercel's own guidance points realtime workloads to external providers rather than long-lived WebSocket connections inside Functions. For a production broadcast-channel use case, run a separate WebSocket service or use a hosted Pusher-protocol provider.
- What is the easiest way to add real-time to a Next.js app in 2026?
- Install pusher-js on the client and pusher-http-node (or the Pusher HTTP library) on the server, point both at a Pusher-protocol service, and you have broadcast channels in a few hours. The client subscribes to a channel; the server publishes events from a route handler or API route. The Pusher Channels protocol is the de facto standard for this pattern and multiple hosted services speak it. Vask is one of them, billed per broadcast rather than per delivered message, which matters once fan-out starts compounding.
- How do I avoid re-initializing the Pusher client on every hot reload in development?
- Attach the Pusher instance to a module-level variable outside the component tree and guard with a typeof check. In development, Next.js hot-reloads modules but preserves the Node.js module cache across reloads if you do it right. The singleton pattern is: check if the instance already exists on globalThis (or a module-level let), return it if so, create and cache it if not. The code sample on this page shows the pattern for both Pages Router and App Router.
- Do private and presence channels work the same in Next.js as in Laravel?
- The wire protocol is identical. Private channels (private-* prefix), presence channels (presence-* prefix), and channel auth are all part of the Pusher Channels protocol. The auth endpoint is an HTTP callback that your Next.js API route or route handler implements; pusher-js hits it automatically when the client subscribes to a private or presence channel. The implementation is slightly different from Laravel (no BroadcastServiceProvider, no routes/channels.php) but the concepts are the same.
Get going
The pusher and pusher-js packages install in under a minute. Credentials from your Vask dashboard go in .env.local, the singleton client prevents hot-reload re-init, and the channel auth endpoint wires presence in a route handler. Most Next.js teams ship the integration in a few hours.
Hosted Pusher protocol on the edge
Same pusher-js client. Different substrate.
Drop in your NEXT_PUBLIC_PUSHER_* credentials, keep the pusher-js subscription code. No first-party WebSocket server to run. Most Next.js teams ship the integration in a few hours.