---
title: Building a Chat App
description: Step-by-step guide to building a real-time chat application with NoLag.
---

# Building a Chat App

Learn how to build a real-time chat application with NoLag, including messaging, presence tracking, and message history.

## Overview

In this guide, you'll learn how to:

- Create the app, room, and actors the chat needs
- Set up real-time messaging between users
- Show who's online with presence tracking
- Load message history with a hydration webhook

## App Setup

Rooms never exist implicitly: subscribing to a room that has not been created fails with `unknown_topic` (42940) and nothing is delivered. Create the app and its room before any client connects. Give every user their own actor: publishers never receive their own messages, so two people chatting through one shared token would never see each other. The only bootstrap secret is a project-scoped [API key](/docs/authentication#api-keys) (`nlg_live_...`), created once in the portal.

```typescript [Setup (server side, once)]
import { NoLagApi } from '@nolag/js-sdk'

const api = new NoLagApi(process.env.NOLAG_API_KEY) // nlg_live_...

// 1. Create the app and declare the topics it allows. The slug that comes back
//    has a random suffix, so read it from the response.
const app = await api.apps.create({ name: 'Chat App', slug: 'chat-app', topics: ['messages'] })
console.log(app.slug) // e.g. "chat-app-a3f9": this is your APP_SLUG

// 2. Create the room. Room slugs are kept exactly as you supply them.
await api.rooms.create(app.appId, { name: 'General', slug: 'general' })

// 3. One actor per user. Each access token is shown once; keep it.
const alice = await api.actors.create({ name: 'alice', actorType: 'user' })
const bob = await api.actors.create({ name: 'bob', actorType: 'user' })
console.log(alice.accessToken, bob.accessToken)
```

**Portal alternative.** Apps, New App, add `messages` to the topic list, then add a room with slug `general` and create one actor per user under Actors. Copy the app slug shown after creation, suffix included.

## Basic Setup

Each user connects with their own actor token and works inside the `general` room. Errors from the broker, such as a misspelled room, arrive on the `error` event rather than as exceptions, so register that handler first.

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'

const APP_SLUG = 'chat-app-a3f9' // the slug returned when you created the app

interface ChatMessage {
  text: string
  sender: string
  timestamp: number
}

const client = NoLag('alice_access_token') // this user's actor token
client.on('error', (err) => console.error(err)) // unknown_topic etc. arrive here
await client.connect()

const chatRoom = client.setApp(APP_SLUG).setRoom('general')

// Subscribe to messages
chatRoom.subscribe('messages')

// Listen for messages from other users
chatRoom.on<ChatMessage>('messages', (message) => {
  displayMessage(message)
})

// Send a message. Publishers never receive their own messages,
// so display it locally as well.
function sendMessage(text: string) {
  const message: ChatMessage = { text, sender: currentUser.name, timestamp: Date.now() }
  chatRoom.emit('messages', message)
  displayMessage(message)
}
```
```python [Python]
import time
from nolag import NoLag

APP_SLUG = 'chat-app-a3f9'  # the slug returned when you created the app

client = NoLag('alice_access_token')  # this user's actor token
client.on('error', lambda err: print(err))  # unknown_topic etc. arrive here
await client.connect()

chat_room = client.set_app(APP_SLUG).set_room('general')

# Subscribe to messages
await chat_room.subscribe('messages')

# Listen for messages from other users
def on_message(message, meta):
    display_message(message)

chat_room.on('messages', on_message)

# Send a message. Publishers never receive their own messages,
# so display it locally as well.
async def send_message(text: str):
    message = {
        'text': text,
        'sender': current_user.name,
        'timestamp': int(time.time() * 1000),
    }
    await chat_room.emit('messages', message)
    display_message(message)
```
```go [Go]
package main

import (
    "log"
    "time"

    nolag "github.com/NoLagApp/go-sdk"
)

const appSlug = "chat-app-a3f9" // the slug returned when you created the app

// Package scope, so sendMessage can reach it from your UI code.
var chatRoom *nolag.Room

func main() {
    client := nolag.New("alice_access_token") // this user's actor token
    client.OnError(func(err *nolag.ServerError) {
        log.Println(err) // unknown_topic etc. arrive here
    })
    if err := client.Connect(); err != nil {
        log.Fatal(err)
    }

    chatRoom = client.SetApp(appSlug).SetRoom("general")

    // Subscribe to messages from other users
    chatRoom.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
        displayMessage(data)
    })

    select {} // keep the process alive; sendMessage is called from your UI
}

// Send a message. Publishers never receive their own messages,
// so display it locally as well.
func sendMessage(text string) {
    message := map[string]any{
        "text":      text,
        "sender":    currentUser.Name,
        "timestamp": time.Now().UnixMilli(),
    }
    chatRoom.Emit("messages", message)
    displayMessage(message)
}
```

## Adding Presence

Presence is room-scoped: set it on the room, and the broker broadcasts join, leave, and update events to the other actors that have set presence in that room. The events themselves are delivered on the client, not on the room.

```typescript [TypeScript]
// Set your presence in the room after connecting
chatRoom.setPresence({
  username: currentUser.name,
  status: 'online',
  avatar: currentUser.avatar
})

// Presence events are delivered on the client
client.on('presence:join', (actor) => {
  addUserToList(actor.presence)
  showNotification(`${actor.presence.username} joined`)
})

client.on('presence:leave', (actor) => {
  removeUserFromList(actor.actorTokenId)
})

// Fetch who is in the room right now
const onlineUsers = await chatRoom.fetchPresence()
```
```python [Python]
# Set your presence in the room after connecting
await chat_room.set_presence({
    'username': current_user.name,
    'status': 'online',
    'avatar': current_user.avatar
})

# Presence events are delivered on the client
def on_join(actor):
    add_user_to_list(actor.presence)
    show_notification(f"{actor.presence['username']} joined")

def on_leave(actor):
    remove_user_from_list(actor.actor_token_id)

client.on('presence:join', on_join)
client.on('presence:leave', on_leave)

# Fetch who is in the room right now
online_users = await chat_room.fetch_presence()
```
```go [Go]
// Set your presence in the room after connecting
chatRoom.SetPresence(map[string]any{
    "username": currentUser.Name,
    "status":   "online",
    "avatar":   currentUser.Avatar,
})

// Presence events are delivered on the client
client.On("presence:join", func(args ...any) {
    actor := args[0].(nolag.ActorPresence)
    addUserToList(actor.Presence)
    fmt.Printf("%v joined\n", actor.Presence["username"])
})

client.On("presence:leave", func(args ...any) {
    actor := args[0].(nolag.ActorPresence)
    removeUserFromList(actor.ActorTokenID)
})

// Fetch who is in the room right now
onlineUsers, err := client.GetPresence("general")
if err != nil {
    log.Println(err)
}
for _, actor := range onlineUsers {
    addUserToList(actor.Presence)
}
```

## Message History

A new subscriber only receives messages published after it subscribed. To show recent history, configure a [hydration webhook](/docs/concepts/webhooks): the broker calls your endpoint once per subscribe and forwards whatever JSON it returns to that subscriber.

1. Configure the webhook on the app. `hydrationWebhook` is the app-wide default; `topicConfigs.messages.webhooks.onSubscribe` overrides it for the `messages` topic alone and wins when both are set.
2. The broker POSTs `{ actorId, roomName, topicName, scope }` to your endpoint. Return the recent messages for that room as a JSON body (`scope` lets you keep tenants apart).
3. The response arrives on the client's `hydration` event with the bare topic name, separate from live `messages` traffic. Register that handler before you subscribe, because the response can follow the subscribe immediately.

The broker reads the webhook configuration when a connection authenticates (and at its periodic revalidation, about every 10 minutes), so configure it before your clients connect.

```typescript [Configure (server side, once)]
import { NoLagApi } from '@nolag/js-sdk'

const api = new NoLagApi(process.env.NOLAG_API_KEY) // nlg_live_...

// `app` is the App returned by api.apps.create in App Setup
await api.apps.update(app.appId, {
  // App-wide default, used by every topic without its own override
  hydrationWebhook: {
    url: 'https://api.example.com/hydrate',
    headers: { Authorization: 'Bearer xxx' }
  },
  // Per-topic override: wins over the app-wide default for `messages`
  topicConfigs: {
    messages: {
      webhooks: { onSubscribe: { url: 'https://api.example.com/chat-history' } }
    }
  }
})
```
```typescript [TypeScript]
// Register before chatRoom.subscribe('messages'): the webhook fires on that subscribe
client.on('hydration', ({ topic, data }) => {
  if (topic === 'messages') {
    for (const message of data as ChatMessage[]) {
      displayMessage(message)
    }
  }
})
```
```python [Python]
# Register before chat_room.subscribe('messages'): the webhook fires on that subscribe
def on_hydration(topic, data):
    if topic == 'messages':
        for message in data:
            display_message(message)

client.on('hydration', on_hydration)
```
```go [Go]
// Register before chatRoom.Subscribe("messages", ...): the webhook fires on that subscribe
client.On("hydration", func(args ...any) {
    topic := args[0].(string)
    data := args[1]
    if topic == "messages" {
        for _, message := range data.([]any) {
            displayMessage(message)
        }
    }
})
```

## Next Steps

- [Rooms](/docs/concepts/rooms)
- [Presence Tracking](/docs/concepts/presence)
- [Webhooks](/docs/concepts/webhooks)
- [Quality of Service](/docs/concepts/qos)
