How to Build In-App Chat in Next.js (App Router) with Nexconn

How to Build In-App Chat in Next.js (App Router) with Nexconn
Ryan Yang
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.
Everything runs on Nexconn's Free Chat Plan (up to 10,000 MAU, no credit card required), so you can follow along without a budget conversation.

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:

  1. Install @nexconn/engine, @nexconn/chat and @nexconn/chatui.
  2. Add a Next.js route handler that returns a Nexconn token for the signed-in user.
  3. In a client component, initialize Nexconn Chat first, then ChatUI, and call app.ready().
  4. Fetch the token and call NCEngine.connect once.
  5. Mount the nc-chat-ui-app-provider element.
  6. 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

npx create-next-app@latest nexconn-chat --ts --app
cd nexconn-chat
npm install @nexconn/engine@latest @nexconn/chat@latest @nexconn/chatui@latest server-only

Add your keys to .env.local:

# 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.

// lib/demo-users.ts
export const DEMO_USERS = [
  { userId: 'ava', name: 'Ava Chen' },
  { userId: 'ben', name: 'Ben Okafor' },
];
// app/api/session/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { DEMO_USERS } from '@/lib/demo-users';

export async function POST(req: Request) {
  const { userId } = await req.json();
  if (!DEMO_USERS.some((u) => u.userId === userId)) {
    return NextResponse.json({ error: 'Unknown user' }, { status: 400 });
  }
  (await cookies()).set('demo_user', userId, {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
  });
  return NextResponse.json({ ok: true });
}

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:

// components/chat-shell.tsx
'use client';

import { useEffect, useRef } from 'react';
import { useChat } from './chat-provider';

export function ChatShell() {
  const { app } = useChat();
  const host = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!app || !host.current) return;
    const root = document.createElement('nc-chat-ui-app-provider');
    root.style.width = '100%';
    root.style.height = '100%';
    host.current.appendChild(root);
    return () => root.remove();
  }, [app]);

  return <div ref={host} style={{ height: '100%' }} />;
}

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.

ChatUI supports direct, group and system channels, and you open any of them with a channel identifier from @nexconn/chat:

import {
  DirectChannelIdentifier,
  GroupChannelIdentifier,
  SystemChannelIdentifier,
} from '@nexconn/chat';

await app.openChannel(new DirectChannelIdentifier('ben'), true);
await app.openChannel(new GroupChannelIdentifier('team-design'), true);
await app.openChannel(new SystemChannelIdentifier('notifications'), true);

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} />;
}
// components/open-direct-channel.tsx
'use client';

import { useEffect } from 'react';
import { useChat } from './chat-provider';

export function OpenDirectChannel({ peerId }: { peerId: string }) {
  const { app, connected } = useChat();

  useEffect(() => {
    if (!app || !connected) return;
    (async () => {
      const { DirectChannelIdentifier } = await import('@nexconn/chat');
      if (app.ifReady()) {
        await app.openChannel(new DirectChannelIdentifier(peerId), true);
      }
    })();
  }, [app, connected, peerId]);

  return null;
}

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:

// components/connection-badge.tsx
'use client';

import { useChat } from './chat-provider';

export function ConnectionBadge() {
  const { connected, status } = useChat();
  return (
    <span aria-live="polite">
      {connected ? 'Connected' : `Connecting… (${status})`}
    </span>
  );
}

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:

app.updateUserProfile({
  userId: 'ava',
  name: 'Ava Chen',
  avatarUrl: 'https://example.com/ava.png',
});

Step 7: Make it yours

Layout and look come first. Item styling is set before ready(), so it goes in the “pre-ready customization” spot in the provider:

const itemConfig = chatUI.cloneChannelsItemConfig();
itemConfig.radius = 12;
itemConfig.portraitSize = 'small';
chatUI.setChannelsItemConfig(itemConfig);

The channel bar and header controls can change any time, so they're handy when your app owns the surrounding navigation:

app.setChannelBarTitle('Messages');
app.setChannelDetailBackSwitch(true);

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

SymptomWhat to check
window is not defined or hydration warningsKeep the SDK imports inside the client effect, as in ChatProvider.
The inbox area is blankMake sure app.ready() ran before you mount nc-chat-ui-app-provider.
Error 39001 when sendingYou called sendMessage or insertMessage before ready().
Error 39005 when opening a channelThe channel list is still syncing. Retry once it has loaded.
Names show up as raw user IDsYour hooks must return a non-empty name for every profile.
Duplicate initialization in developmentKeep the cancelled guard and the module-level flags from Step 3.

Next steps

Contact us
Contact us
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.

Related Articles

Build a Real-Time Flutter Chat App for Free (2026 Tutorial)

Build a Real-Time Flutter Chat App for Free (2026 Tutorial)

Home > Blog > Build a Flutter Chat App for Free Flutter makes it possible to build one product experience for iOS and Android, but a reliable chat feature still needs more than a message list and a WebSocket connection. Mobile networks change, apps move between foreground and background, users sign in on multiple devices, and missed messages must be synchronized after reconnecting. To support production-oriented integrations from day one, this tutorial uses the Nexconn Production-Re

Build Real-Time Chat Across Web, iOS, Android, and Flutter with Nexconn

Build Real-Time Chat Across Web, iOS, Android, and Flutter with Nexconn

Home > Blog > Build Real-Time Chat Across Platforms Deploy production-grade in-app chat across Web, iOS, Android, and Flutter at $0 with our Free Multi-Platform Chat SDK (10K MAU). Adding a message input and a WebSocket is easy. Building chat that survives network changes, app restarts, offline periods, multiple devices, media uploads, unread state, group growth, and production traffic is a different problem. This guide shows the shortest practical path to real-time in-app chat wit

Scaling Unlimited Live Chat: Architecture, Consistent Hashing, and Notification-Pull

Scaling Unlimited Live Chat: Architecture, Consistent Hashing, and Notification-Pull

Home > Blog > Scaling Unlimited Live Chat Live streaming has a concurrency problem that most infrastructure wasn't designed to solve. When a major broadcast goes live — a national event, a product launch, a celebrity stream — viewer counts can spike from thousands to millions within minutes. Every one of those viewers expects to see the chat moving in real time. They want to send messages, receive reactions, and feel like they're part of something happening right now. Any lag, any d