If you've ever used Websockets to add real-time features to a web app, you know how powerful WebSockets can be — and how quickly costs can spiral. Apinator is a drop-in alternative: a hosted real-time messaging platform with a Pusher-compatible SDK, private/presence channel support, and usage-based pricing that doesn't punish you for growing.
In this tutorial we'll build a fully working real-time chat room from scratch using Next.js 14 (App Router) and Apinator. By the end you'll have:
- A live chat UI where messages appear instantly for all connected users
- Private channel authentication (no anonymous broadcasting)
- Presence indicators showing who's currently online
- Clean, production-ready code you can extend for notifications, live cursors, or collaborative editing
What is Apinator?
Apinator provides hosted WebSocket infrastructure so you don't have to manage your own socket servers. You publish events from your backend; your frontend clients receive them in milliseconds across any number of connected users.
Key concepts:
| Concept | Description |
|---|---|
| App | An isolated namespace with its own key/secret pair |
| Channel | A named pub/sub topic clients subscribe to |
| Public channel | Anyone can subscribe (e.g. chat-room) |
| Private channel | Requires server-side auth (prefix: private-) |
| Presence channel | Auth + online member list (prefix: presence-) |
| Event | A named message published to a channel |
Prerequisites
- Node.js 18+
- A free apinator.io account
- Basic familiarity with Next.js App Router
Step 1 — Create an Apinator App
- Sign in to the Apinator console
- Click New App, give it a name (e.g.
nextjs-chat), choose a cluster (euorus) - From the app's Keys page, copy:
- App ID
- Key (public)
- Secret (keep this server-side only)
Step 2 — Bootstrap the Next.js Project
npx create-next-app@latest realtime-chat --typescript --tailwind --app
cd realtime-chat
Install the Apinator SDKs:
npm install @apinator/client @apinator/server
-
@apinator/client— browser client (WebSocket, channel subscriptions) -
@apinator/server— server-side client (trigger events, sign channel auth)
Add your credentials to .env.local:
# Public — safe to expose to the browser
NEXT_PUBLIC_APINATOR_KEY=your_key
NEXT_PUBLIC_APINATOR_CLUSTER=eu
# Private — server only
APINATOR_APP_ID=your_app_id
APINATOR_SECRET=your_secret
Step 3 — Server-Side: Channel Auth Endpoint
Private channels require your server to sign the subscription. Create the auth route:
// app/api/auth/channel/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Apinator } from "@apinator/server";
const apinator = new Apinator({
appId: process.env.APINATOR_APP_ID!,
key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
secret: process.env.APINATOR_SECRET!,
cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
});
export async function POST(req: NextRequest) {
const { socket_id, channel_name, username } = await req.json();
if (!socket_id || !channel_name) {
return NextResponse.json({ error: "Missing params" }, { status: 400 });
}
// In a real app, verify the user's session here before signing
const channelData =
channel_name.startsWith("presence-")
? JSON.stringify({
user_id: socket_id, // use a real user ID from your auth system
user_info: { name: username ?? "Anonymous" },
})
: undefined;
const auth = apinator.authenticateChannel(socket_id, channel_name, channelData);
return NextResponse.json(auth);
}
Step 4 — Server-Side: Send Messages
Add an API route to receive new messages and broadcast them via Apinator:
// app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Apinator } from "@apinator/server";
const apinator = new Apinator({
appId: process.env.APINATOR_APP_ID!,
key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
secret: process.env.APINATOR_SECRET!,
cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
});
export async function POST(req: NextRequest) {
const { text, username, socketId } = await req.json();
if (!text?.trim()) {
return NextResponse.json({ error: "Empty message" }, { status: 400 });
}
await apinator.trigger({
name: "new-message",
channel: "presence-chat-room",
data: JSON.stringify({
text: text.trim(),
username: username ?? "Anonymous",
timestamp: Date.now(),
}),
// Exclude the sender's own socket so they don't receive an echo
socketId,
});
return NextResponse.json({ ok: true });
}
The socketId exclusion is optional but gives a snappier UX — the sender sees their message immediately from local state while everyone else receives it via WebSocket.
Step 5 — Client-Side: The Chat Hook
Create a custom hook that manages the Apinator connection and channel subscription:
// hooks/useChat.ts
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { Apinator, type PresenceChannel } from "@apinator/client";
export type ChatMessage = {
id: string;
text: string;
username: string;
timestamp: number;
self: boolean;
};
export type OnlineMember = {
user_id: string;
user_info: { name: string };
};
export function useChat(username: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [members, setMembers] = useState<OnlineMember[]>([]);
const [connected, setConnected] = useState(false);
const clientRef = useRef<Apinator | null>(null);
const socketIdRef = useRef<string | null>(null);
useEffect(() => {
const client = new Apinator({
key: process.env.NEXT_PUBLIC_APINATOR_KEY!,
cluster: process.env.NEXT_PUBLIC_APINATOR_CLUSTER! as "eu" | "us",
authEndpoint: "/api/auth/channel",
// Pass the username so the auth endpoint can include it in channel_data
authHeaders: { "x-username": username },
});
client.connect();
clientRef.current = client;
client.bind("state_change", ({ current }: { current: string }) => {
setConnected(current === "connected");
});
// Subscribe to a presence channel so we get the online member list
const channel = client.subscribe("presence-chat-room") as PresenceChannel;
channel.bind("realtime:subscription_succeeded", () => {
const memberList = channel.getMembers() as OnlineMember[];
setMembers(memberList);
});
channel.bind("realtime:member_added", (member: OnlineMember) => {
setMembers((prev) => [...prev, member]);
});
channel.bind("realtime:member_removed", ({ user_id }: { user_id: string }) => {
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
});
channel.bind("new-message", (data: Omit<ChatMessage, "id" | "self">) => {
setMessages((prev) => [
...prev,
{
...data,
id: `${data.timestamp}-${Math.random()}`,
self: false,
},
]);
});
return () => {
client.unsubscribe("presence-chat-room");
client.disconnect();
};
}, [username]);
const sendMessage = useCallback(
async (text: string) => {
if (!text.trim()) return;
const socketId = clientRef.current?.socketId ?? undefined;
// Optimistically add the message locally
setMessages((prev) => [
...prev,
{
id: `${Date.now()}-self`,
text,
username,
timestamp: Date.now(),
self: true,
},
]);
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, username, socketId }),
});
},
[username]
);
return { messages, members, connected, sendMessage };
}
Step 6 — Client-Side: Chat UI
// app/chat/page.tsx
"use client";
import { useState, useRef, useEffect, FormEvent } from "react";
import { useChat } from "@/hooks/useChat";
export default function ChatPage() {
const [username] = useState(
() => `User${Math.floor(Math.random() * 1000)}`
);
const { messages, members, connected, sendMessage } = useChat(username);
const [input, setInput] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!input.trim()) return;
await sendMessage(input);
setInput("");
}
return (
<div className="flex h-screen bg-gray-50">
{/* Sidebar — online members */}
<aside className="w-56 bg-white border-r p-4 flex flex-col gap-2">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
Online — {members.length}
</h2>
{members.map((m) => (
<div key={m.user_id} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-400" />
<span className="text-sm text-gray-700">{m.user_info.name}</span>
</div>
))}
</aside>
{/* Chat area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<header className="border-b px-6 py-3 bg-white flex items-center gap-3">
<span className="font-semibold text-gray-800">#general</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
connected
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{connected ? "Connected" : "Connecting…"}
</span>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex flex-col ${msg.self ? "items-end" : "items-start"}`}
>
<span className="text-xs text-gray-400 mb-1">{msg.username}</span>
<div
className={`px-4 py-2 rounded-2xl text-sm max-w-xs ${
msg.self
? "bg-blue-500 text-white rounded-tr-sm"
: "bg-white text-gray-800 shadow-sm rounded-tl-sm"
}`}
>
{msg.text}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<form
onSubmit={handleSubmit}
className="border-t px-6 py-4 bg-white flex gap-3"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message…"
className="flex-1 rounded-full border px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-300"
/>
<button
type="submit"
disabled={!input.trim() || !connected}
className="bg-blue-500 text-white rounded-full px-5 py-2 text-sm font-medium disabled:opacity-40"
>
Send
</button>
</form>
</div>
</div>
);
}
Step 7 — Fix the Auth Endpoint to Use Headers
Update the auth route to read the username from the request header:
// app/api/auth/channel/route.ts (updated section)
const username = req.headers.get("x-username") ?? "Anonymous";
Run It
npm run dev
Open http://localhost:3000/chat in two browser tabs. Type a message in one — it appears instantly in the other.
How It Works (Architecture Overview)
Browser A Next.js Server Apinator
| | |
|-- POST /api/messages ------>| |
| |-- trigger(event) ------>|
| | |
|<-- WebSocket push ------------------------------------|
Browser B |
|<-- WebSocket push ------------------------------------|
- User submits a message → hits your Next.js API route
- API route calls
apinator.trigger()to publish the event - Apinator fans out the event to all connected WebSocket clients subscribed to that channel
- The sender's own socket is excluded (via
socketId) to avoid double-rendering
What to Build Next
Private DMs — Create a channel per user pair: private-dm-{userId1}-{userId2}. The auth endpoint checks that the requesting user is one of the participants.
Typing indicators — Use Apinator's client events (client- prefix). These travel peer-to-peer through the server without hitting your API:
channel.trigger("client-typing", { username });
channel.bind("client-typing", ({ username }) => showTyping(username));
Notifications — Subscribe users to a private private-user-{id} channel on login. Trigger events from any backend service to push notifications in real time.
Webhook processing — Use client.verifyWebhook(headers, body) to securely receive delivery confirmations and channel lifecycle events from Apinator.
Closing Thoughts
Apinator gets you from zero to real-time in under an hour with a clean, well-typed SDK that feels right at home in a TypeScript/Next.js project. The same mental model (apps → channels → events) scales from a weekend project to production traffic without re-architecting.
Top comments (0)