← Use cases

Next.js on Vercel and serverless

Publish from server actions, subscribe in client components, keep sockets out of your functions.

A Next.js app on Vercel, Netlify, Cloudflare or AWS Lambda runs its server code per request. A function that lives for a few hundred milliseconds cannot hold thousands of WebSocket connections open, so "add live updates" usually ends in polling or a separate server to run. With Hibira, your route handlers and server actions publish with one short HTTPS call, and the browsers keep their connection to Hibira instead of to your functions.

What your users get

  • Order, job and payment status that changes on screen the moment it changes in your database.
  • Notifications and comments that appear in every open tab, including tabs opened from a shared link.
  • Pages that stay fast: the first render still comes from your server components.

Built with

  • pusher (the Node.js server library) in route handlers and server actions.
  • pusher-js in client components.
  • Private channels (private-), authorized by a route handler that checks your session.
  • Socket exclusion (socket_id), so the user who made the change does not get it twice.

Step 1: environment variables

PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_SECRET=YOUR_APP_SECRET
NEXT_PUBLIC_PUSHER_KEY=YOUR_APP_KEY
NEXT_PUBLIC_PUSHER_HOST=ws.hibira.io

Only the key and host get the NEXT_PUBLIC_ prefix, because they are sent to the browser. The secret never is.

Step 2: a server-only client

// lib/pusher-server.ts
import 'server-only';
import Pusher from 'pusher';

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_APP_SECRET!,
  host: process.env.NEXT_PUBLIC_PUSHER_HOST!,
  useTLS: true,
});

Install both libraries with npm install pusher pusher-js server-only. The server-only import makes the build fail if a client component ever imports this file, so the secret cannot end up in the browser bundle.

Step 3: publish from a server action

// app/orders/actions.ts
'use server';
import { pusher } from '@/lib/pusher-server';

export async function shipOrder(id: string, socketId?: string) {
  const order = await db.orders.ship(id);
  await pusher.trigger(`private-order-${id}`, 'status', { status: order.status }, { socket_id: socketId });
  return order;
}

Route handlers, webhooks from your payment provider and cron jobs publish the same way. Always await the call: serverless platforms can freeze the function as soon as the response is sent.

Step 4: authorize private channels

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

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });
  const form = await req.formData();
  const socketId = String(form.get('socket_id'));
  const channel = String(form.get('channel_name'));
  const id = channel.replace(/^private-order-/, '');
  if (!(await db.orders.belongsTo(id, session.user.id))) return new Response('Forbidden', { status: 403 });
  return Response.json(pusher.authorizeChannel(socketId, channel));
}

auth() here is Auth.js; use whatever your app already uses to read the session.

Step 5: subscribe in a client component

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

let client: Pusher | undefined;

export function getPusher(): Pusher {
  client ??= new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
    cluster: 'ap',
    wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST!,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: { endpoint: '/api/pusher/auth', transport: 'ajax' },
  });
  return client;
}
// app/orders/[id]/order-status.tsx
'use client';
import { useEffect, useState } from 'react';
import { getPusher } from '@/lib/pusher-client';

export function OrderStatus({ id, initial }: { id: string; initial: string }) {
  const [status, setStatus] = useState(initial);
  useEffect(() => {
    const name = `private-order-${id}`;
    const channel = getPusher().subscribe(name);
    const onStatus = (data: { status: string }) => setStatus(data.status);
    channel.bind('status', onStatus);
    return () => {
      channel.unbind('status', onStatus);
      getPusher().unsubscribe(name);
    };
  }, [id]);
  return <p>{status}</p>;
}

The server component renders the current status and passes it as initial; the client component keeps it fresh. The client is created inside useEffect, so nothing connects during server rendering.

Tips

  • Keep the auth route and the code that publishes on the Node.js runtime (the default): the pusher library uses Node's crypto.
  • To skip the user who made the change, pass getPusher().connection.socket_id to the server action, as shipOrder does above.
  • If several components share a channel, subscribe once in a common parent, or count users before you unsubscribe, so one unmounting component does not cut off the others.
  • To reuse your server components instead of sending the data, call router.refresh() when the event arrives and let Next.js fetch the new render.
  • The Node.js guide and JavaScript guide have these settings filled in with your app's values once you sign in.