How to Build In-App Chat in Next.js (App Router) with Nexconn
Ryan Yang
Nexconn Infrastructure Engineer. Optimizes latency and scales microservices for hundreds of millions of concurrent users. Shares technical deep dives and backend lessons for zero-latency communication.
The ticket sounds harmless: let buyers message sellers before they check out. Your Next.js app already has sign-in, listings and a checkout flow, so how hard can chat be?
Then the real questions show up. Who is allowed to open a connection, and how do you prove it without putting a secret in the browser? What happens to the inbox and the unread counts when a buyer has the app open in three tabs? Who decides that someone is “online,” and how does the UI find out? Plenty of tutorials dodge all of this by wiring a text box to a WebSocket. That makes a nice demo and a painful launch.
This guide builds the version you can ship. You'll create a Next.js App Router app where your server decides who gets to connect, Nexconn handles the real-time plumbing, and ChatUI gives you a working inbox without hand-building a message list. Along the way you’ll set up:
Auth: a route handler that issues a chat token only to the signed-in user.
Channels: an inbox, plus deep links like /chat/ben that open a direct conversation.
Presence: connection status for the current user and online indicators for everyone else.
Everything runs on Nexconn's free plan (up to 10,000 monthly active users, no credit card to get started), so you can follow along without a budget conversation.
The short version
If you just want the outline, here it is:
Install @nexconn/engine, @nexconn/chat and @nexconn/chatui.
Add a Next.js route handler that returns a Nexconn token for the signed-in user.
In a client component, initialize Nexconn Chat first, then ChatUI, and call app.ready().
Fetch the token and call NCEngine.connect once.
Mount the nc-chat-ui-app-provider element.
Open conversations with app.openChannel and feed online status to ChatUI with app.updateUserOnlineStatus.
The rest of this guide walks through each step with code.
How the pieces fit
Here's the shape of the app before we write any of it:
Your server owns identity. A route handler checks the session and returns a Nexconn token for that user.
The browser owns the connection. A client-side provider initializes Nexconn Chat and ChatUI, fetches the token and connects once.
ChatUI owns the screen. It renders channels, messages, the input box and file and image uploads, and asks you for profile data through a few hooks.
Your product owns the rules. Who can message whom is decided in your own backend. Nexconn only needs the peer’s user ID.
Prerequisites
Node.js 20 or later, and a Next.js 15+ project using the App Router and TypeScript.
A Nexconn account. After you sign up, the console creates an app and issues an App Key, with no credit card required. Copy the App Key and App Secret from App Key management. The App Key goes to the browser. The App Secret never does. Each app has a single App Key, so you can use the same one across development, testing and release.
Two test users. In the console's developer tools (the API playground), register two users, ava and ben, and copy a token for each.
A current browser. ChatUI supports Chrome 68+, Safari 13+ and Edge 100+, and it’s designed for desktop and landscape layouts, which suits marketplaces, dashboards and support consoles. If you need a portrait phone layout, you can build your own UI on top of the Chat SDK. This guide uses ChatUI to get you to a working inbox quickly.
The snippets match the 26.2 releases: @nexconn/engine and @nexconn/chat 26.2.1, and @nexconn/chatui 26.2.5. Keep the three packages on matching versions. The MESSAGE_WILL_SEND event used later needs ChatUI 26.2.4 or newer.
Step-by-Step Implementation Guide
Step 1: Create the project and install the packages
# Safe to expose: the App Key is used by the browser SDK
NEXT_PUBLIC_NEXCONN_APP_KEY=your-app-key
# Server-only: test tokens from the console, keyed by user ID
NEXCONN_TEST_TOKENS={"ava":"<token-for-ava>","ben":"<token-for-ben>"}
The NEXT_PUBLIC_ prefix tells Next.js to inline a value into the browser bundle. That’s right for the App Key and wrong for anything secret, which is why the tokens (and later your App Secret) have no prefix.
Here's where everything will live:
app/
page.tsx # demo sign-in
api/
session/route.ts # sets the demo session cookie
chat/token/route.ts # issues a Nexconn token
profiles/route.ts # profile data for ChatUI
chat/
layout.tsx # provider + inbox
page.tsx
[peerId]/page.tsx # opens a direct conversation
components/
chat-provider.tsx
chat-shell.tsx
connection-badge.tsx
open-direct-channel.tsx
lib/
demo-users.ts
nexconn-server.ts
Step 2: Authentication, or who gets a token
A Nexconn token maps to a user ID and authenticates the client. That has one important consequence: anyone who can request a token for a given user ID can act as that user. So issuing a token is an authorization decision, and authorization belongs on your server. The client never mints tokens, and your App Secret never ships to the browser.
First, a stand-in for your real sign-in. A cookie keeps the demo short; in production you'd use whatever you already have, such as Auth.js, Clerk or your own sessions.
Next, the only place in your app that knows how a token is produced:
// lib/nexconn-server.ts
import 'server-only';
// Development: test tokens created in the Nexconn Console.
// Production: call the Register User endpoint of the Nexconn Server API here.
export async function issueChatToken(userId: string): Promise<string> {
const tokens: Record<string, string> = JSON.parse(
process.env.NEXCONN_TEST_TOKENS ?? '{}',
);
const token = tokens[userId];
if (!token) {
throw new Error(`No test token configured for "${userId}"`);
}
return token;
}
And the route handler the browser will call:
// app/api/chat/token/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { issueChatToken } from '@/lib/nexconn-server';
export async function POST() {
const userId = (await cookies()).get('demo_user')?.value;
if (!userId) {
return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
}
const token = await issueChatToken(userId);
return NextResponse.json({ userId, token }, { headers: { 'Cache-Control': 'no-store' } });
}
The server-only import makes the build fail if someone imports this file from a client component, which is a cheap guardrail for secrets.
When you move to production, only issueChatToken changes. Its body becomes a signed server-to-server call to the Register User endpoint of the Nexconn Server API, using your App Key and App Secret from server-side environment variables. The response carries the token:
{ "code": 200, "userId": "ava", "token": "..." }
The route handler, the client and the rest of the app stay exactly as they are. Since the console is where your App Secret lives, it’s also worth turning on two-factor authentication for dashboard members in the console’s organization settings.
Step 3: Initialize Nexconn Chat and ChatUI on the client
Order matters here: initialize the Chat engine first, then ChatUI. ChatUI renders Web Components and the SDK talks to the browser’s network stack, so we keep both on the client and load them with a dynamic import inside an effect. That way nothing browser-specific ever runs during server rendering.
ChatUI also asks you for display data (names, avatars, group rosters) through ServiceHooks. Start with a profiles route that answers from your own data:
// app/api/profiles/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { DEMO_USERS } from '@/lib/demo-users';
export async function GET(req: Request) {
if (!(await cookies()).get('demo_user')) {
return NextResponse.json([], { status: 401 });
}
const ids = new URL(req.url).searchParams.get('users')?.split(',') ?? [];
const profiles = ids.map(
(id) => DEMO_USERS.find((u) => u.userId === id) ?? { userId: id, name: id },
);
return NextResponse.json(profiles);
}
Every profile needs a non-empty name, and group profiles also need a memberCount of zero or more. ChatUI caches what you return, so each user is fetched once.
Now the provider that ties it together:
// components/chat-provider.tsx
'use client';
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { NCChatUIApplication, ServiceHooks } from '@nexconn/chatui';
type ChatContextValue = {
app: NCChatUIApplication | null;
connected: boolean;
status: string;
};
const ChatContext = createContext<ChatContextValue>({
app: null,
connected: false,
status: 'idle',
});
export const useChat = () => useContext(ChatContext);
// Both of these should happen once per page load.
let engineInitialized = false;
let sessionConnected = false;
const hooks: ServiceHooks = {
async reqUserProfiles(userIds) {
const res = await fetch(`/api/profiles?users=${encodeURIComponent(userIds.join(','))}`);
return res.json();
},
async reqGroupProfiles(groupIds) {
return groupIds.map((groupId) => ({ groupId, name: groupId, memberCount: 0 }));
},
async reqGroupMembers() {
return [];
},
async reqSystemProfiles(systemIds) {
return systemIds.map((systemId) => ({ systemId, name: 'Notifications' }));
},
};
export function ChatProvider({ children }: { children: ReactNode }) {
const [app, setApp] = useState<NCChatUIApplication | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState('connecting');
useEffect(() => {
let cancelled = false;
let chatUI: NCChatUIApplication | null = null;
async function start() {
const [chat, ui] = await Promise.all([
import('@nexconn/chat'),
import('@nexconn/chatui'),
]);
if (cancelled) return;
// 1. Nexconn Chat first
if (!engineInitialized) {
chat.NCEngine.initialize({
appKey: process.env.NEXT_PUBLIC_NEXCONN_APP_KEY!,
logLevel: chat.LogLevel.WARN,
// areaCode: chat.AreaCode.SG, // set this if your App Key isn't in the default data center
});
engineInitialized = true;
}
// 2. Then ChatUI
chatUI = ui.NCChatUIApplication.initialize({ hooks, language: 'en_US' });
if (!chatUI) throw new Error('ChatUI failed to initialize');
// Any pre-ready customization goes here (see "Make it yours")
chatUI.ready();
// 3. Watch the connection, then connect
chat.NCEngine.addConnectionStatusHandler(
'web-app',
new chat.ConnectionStatusHandler({
onConnectionStatusChanged({ status }) {
if (!cancelled) setStatus(String(status));
},
}),
);
if (!sessionConnected) {
const res = await fetch('/api/chat/token', { method: 'POST' });
if (!res.ok) throw new Error('Could not get a chat token');
const { token } = await res.json();
if (cancelled) return;
const result = await chat.NCEngine.connect({ token });
if (cancelled) return;
if (!result.isOk) {
setStatus(`connect failed (${result.code})`);
return;
}
sessionConnected = true;
}
setApp(chatUI);
setConnected(true);
}
start().catch(console.error);
return () => {
cancelled = true;
chatUI?.destroy();
};
}, []);
const value = useMemo(() => ({ app, connected, status }), [app, connected, status]);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
A few details are doing quiet work here:
The cancelled flag handles React Strict Mode. In development, React mounts, unmounts and remounts every effect. The first run is cancelled before the SDKs finish loading, so you get exactly one initialization instead of two.
connect runs once. The Chat SDK reconnects on its own when the network drops, so you call connect once per app lifecycle and listen to the status handler instead of retrying yourself.
destroy() runs on unmount, so leaving the chat area cleans up ChatUI.
areaCode matters if your App Key lives outside the default data center. Values like AreaCode.SG and AreaCode.NA are available.
Step 4: Mount the inbox in the App Router
After ready(), ChatUI registers its custom elements, and you mount a single root element. A small client component owns that:
Creating the element in an effect sidesteps JSX typing for custom elements and keeps the mount strictly after ready(). Now the layout that wires the pieces together:
// app/chat/layout.tsx
import type { ReactNode } from 'react';
import { ChatProvider } from '@/components/chat-provider';
import { ChatShell } from '@/components/chat-shell';
import { ConnectionBadge } from '@/components/connection-badge';
export default function ChatLayout({ children }: { children: ReactNode }) {
return (
<ChatProvider>
<header>
<ConnectionBadge />
</header>
<div style={{ height: '70vh' }}>
<ChatShell />
</div>
{children}
</ChatProvider>
);
}
Keep the provider as high in the tree as the signed-in session lives. If other pages, such as a listing page with a “Message seller” button, need the same connection, move the provider up to the layout that wraps them.
A simple sign-in page and an inbox landing page complete the loop:
// app/page.tsx
'use client';
import { useRouter } from 'next/navigation';
export default function Home() {
const router = useRouter();
async function signIn(userId: string) {
await fetch('/api/session', { method: 'POST', body: JSON.stringify({ userId }) });
router.push('/chat');
}
return (
<main>
<h1>Marketplace demo</h1>
<p>Sign in as a demo user:</p>
<button onClick={() => signIn('ava')}>Ava (buyer)</button>
<button onClick={() => signIn('ben')}>Ben (seller)</button>
</main>
);
}
// app/chat/page.tsx
import Link from 'next/link';
import { cookies } from 'next/headers';
import { DEMO_USERS } from '@/lib/demo-users';
export default async function ChatHome() {
const me = (await cookies()).get('demo_user')?.value;
const others = DEMO_USERS.filter((u) => u.userId !== me);
return (
<nav>
{others.map((u) => (
<Link key={u.userId} href={`/chat/${u.userId}`}>
Message {u.name}
</Link>
))}
</nav>
);
}
Run npm run dev, sign in as Ava in one browser profile and Ben in another, and the inbox is live.
Step 5: Channels, from the inbox to deep links
ChatUI supports direct, group and system channels, and you open any of them with a channel identifier from @nexconn/chat:
If a channel isn’t in the list yet, ChatUI adds a row for it at the top. That makes deep links straightforward. Here’s a /chat/[peerId] route, where the server decides whether the conversation is allowed and the client opens it:
// app/chat/[peerId]/page.tsx
import { cookies } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { DEMO_USERS } from '@/lib/demo-users';
import { OpenDirectChannel } from '@/components/open-direct-channel';
export default async function DirectChatPage({
params,
}: {
params: Promise<{ peerId: string }>;
}) {
const { peerId } = await params;
const me = (await cookies()).get('demo_user')?.value;
if (!me) redirect('/');
if (peerId === me || !DEMO_USERS.some((u) => u.userId === peerId)) notFound();
return <OpenDirectChannel peerId={peerId} />;
}
This is where “your product owns the rules” pays off. A direct chat only needs the peer's user ID, and friend relationships and messaging policy live in your backend. In a real marketplace, the server component above is the place to check things like “has this buyer viewed this seller’s listing?” before the page renders at all.
Groups work the same way once they exist. You create and manage them through the Chat SDK or the Server API, and ChatUI renders them in the list and detail views. Roles, invites and announcements stay in your product layer, so those rules live in your code instead of ours.
You can also send from code, for example an automatic first message about a listing:
import { DirectChannelIdentifier, SendTextMessageParams } from '@nexconn/chat';
const result = await app.sendMessage(
new DirectChannelIdentifier('ben'),
new SendTextMessageParams({ text: 'Hi! Is this still available?' }),
);
if (!result.isOk) {
console.error('send failed', result.code);
}
Prefer app.sendMessage over low-level send calls here, so the list and the engine stay in sync.
Step 6: Presence, or who’s around
Presence comes in two flavors, and ChatUI makes both easy.
Your own connection. The status handler from Step 3 already reports connection changes, including reconnects. A small badge turns that into something users can read:
The status value is an enum from the SDK. Log it once to see the values you want to map to friendlier labels.
Other people. ChatUI shows an online indicator for a user once you tell it that user is online. It’s one call:
app.updateUserOnlineStatus('ben', true);
The interesting question is where true comes from. The natural pattern is to subscribe to online status through the Chat SDK for the people on screen and forward each change to ChatUI:
// Call this from your online-status subscription callback
function showPresence(app: NCChatUIApplication, userId: string, isOnline: boolean) {
app.updateUserOnlineStatus(userId, isOnline);
}
Keep the subscription list small, the handful of conversation partners the user can actually see, rather than your whole user table. When you subscribe, apply the initial state the same way you apply later changes. If your product already has its own presence signal, feed that in instead. ChatUI doesn’t care where the boolean comes from.
Profile data follows the same push model. ChatUI caches what your hooks return, so when a user changes their name or avatar, tell it directly:
Next, business context. In a marketplace, every message should know which listing it’s about. MESSAGE_WILL_SEND fires before ChatUI creates and sends a message, and it covers text, images, files, forwards and resends, so you set the metadata once:
import { ChatUIEvents, type MessageWillSendEvent } from '@nexconn/chatui';
app.addEventListener(ChatUIEvents.MESSAGE_WILL_SEND, (evt: MessageWillSendEvent) => {
evt.data.params.content.extra = JSON.stringify({ listingId: getActiveListingId() });
evt.data.params.pushContent = { title: 'New message about your listing' };
});
Make changes inside the listener synchronously. The event isn’t a cancellable send hook, and async edits can land after the message has already been queued.
From here you can add custom message types (a “Make an offer” card is a classic), tweak the channel and message context menus, or extend the input toolbar. The ChatUI docs cover each one.
Production Checklist
Swap the stand-ins. Replace the demo cookie with your real session, and replace the body of issueChatToken with the Register User call. Nothing else in the app has to change.
Keep secrets where they belong. The App Key is public. The App Secret stays in server-side environment variables, and the console account that holds it deserves two-factor authentication.
Gate conversations on the server. Check who may message whom in your server components or route handlers, as we did for /chat/[peerId].
Keep profiles fresh. Call updateUserProfile and updateGroupProfile whenever the underlying data changes.
Clean up. Call destroy() when the chat area unmounts. Connect once per app lifecycle and let the SDK handle reconnects.
Match your data center. Set areaCode if your App Key isn’t in the default one.
Troubleshooting
Symptom
What to check
window is not defined or hydration warnings
Keep the SDK imports inside the client effect, as in ChatProvider.
The inbox area is blank
Make sure app.ready() ran before you mount nc-chat-ui-app-provider.
Error 39001 when sending
You called sendMessage or insertMessage before ready().
Error 39005 when opening a channel
The channel list is still syncing. Retry once it has loaded.
Names show up as raw user IDs
Your hooks must return a non-empty name for every profile.
Duplicate initialization in development
Keep the cancelled guard and the module-level flags from Step 3.
Prefer to let your AI coding tool do the scaffolding? The Nexconn Chat Integration Skill generates client code and a backend token endpoint from a single prompt.
Browse the Chat UI source on GitHub if you want to customize components more deeply.
We'd love to discuss how Nexconn's real-time communication solutions can support your business. Request a demo, explore pricing, or get tailored onboarding guidance.