How to Build a Multi‑Tenant Chat System with Go and Stream

New
15 min read
Ekemini Samuel
Ekemini Samuel
Published June 17, 2025

Software applications require scalable, real-time communication features, whether for messaging apps, customer support chats, or collaborative workspaces. Implementing chat functionality from scratch is complex, but you can build a robust solution efficiently with Go and Stream.

This guide walks you through creating a multi-tenant chat system where each organization (tenant) has its own isolated chat environment.

But first —

What is Go?

Go, or Golang, is a statically typed, compiled language developed by Google. It’s renowned for its simplicity, performance, and concurrency features, thanks to goroutines and channels, making it ideal for backend development, APIs, and real-time applications like chat systems.

What is Stream?

Stream provides APIs and SDKs to build scalable, real-time features like chat, activity feeds, and video. With Stream’s Chat SDK, you can skip the complexity of managing WebSockets or message queues and focus on your application’s logic.

In This Tutorial

You’ll:

  1. Set up a Go backend for authentication and chat management.
  2. Integrate the Stream Chat SDK for real-time messaging.
  3. Connect a Next.js frontend to interact with the backend and Stream.

The complete code is available in this GitHub repository. It is a mono-repository structure with the backend and frontend directories.

Prerequisites

  • Go: Installed from golang.org.
  • Stream Account: Sign up at getstream.io for API credentials.
  • PostgreSQL: A database instance (local or cloud-based). Download at postgresql.org.
  • Node.js: For the frontend. Available at nodejs.org.
  • Git: To clone the repository.
  • Basic Knowledge: Familiarity with Go, RESTful APIs, Next.js, and web concepts.

Project Setup

We have the frontend and backend in one repository:

Since this article focuses on integrating Stream with Go, we will primarily work on the backend.

First, clone the repository to your local machine:

bash
1
2
git clone https://github.com/Tabintel/multi-tenant-chat.git` cd multi-tenant-chat`

You’ll work with two main directories: /backend (Go) and /frontend (Next.js). Let’s set up the backend first.

Create a .env file in the backend/cmd directory, with the following variables (replace with your values):

bash
1
2
3
4
DATABASE_URL=postgresql://<username>:<password>@<host>/<database>?sslmode=require` STREAM_API_KEY=your_stream_api_key` STREAM_API_SECRET=your_stream_api_secret` JWT_SECRET=your_jwt_secret`

Backend Architecture

The backend is built with Go and follows a modular, organized structure. Here’s an overview of the directories and their purposes:

text
1
2
3
4
5
6
7
8
9
10
11
multi-tenant-chat` backend/` ├─ cmd/ # Main application entry point` ├─ controllers/ # HTTP request handlers` ├─ db/ # Database connection and migrations` ├─ docs/ # API documentation (Swagger)` ├─ handlers/ # Business logic handlers` ├─ middleware/ # HTTP middleware` ├─ models/ # Database models` ├─ services/ # Stream chat SDK and Channels services` └─ utils/ # Utility functions`

Note: When starting a new Go project, you initialize a Go module using the go mod init command. In this context, it will be:*

bash
1
2
cd backend go mod init github.com/Tabintel/multi-tenant-chat/backend

The multi-tenant chat system divides responsibilities between two key directories: controllers and handlers.

Controllers (backend/controllers/)

  • auth.go: Handles login authentication and token generation
  • channel.go: Manages channel listing and creation
  • chat.go: Generates Stream Chat tokens
  • tenant.go: Controls tenant creation and listing
  • user.go: Manages user CRUD operations

Handlers (backend/handlers/)

  • auth.go: Implements login and registration logic with JWT generation
  • channel.go: Contains channel creation and listing with Stream integration
  • stream.go: Manages Stream Chat token generation and messaging
  • tenant_user.go: Implements tenant and user management logic

This separation follows the MVC (Model-View-Controller) pattern, creating more maintainable and testable code. Controllers focus on request management while handlers execute the business logic.

  1. Handling HTTP Requests and Authentication

Create a /controllers directory in the backend. This will manage the RESTful API endpoints in the chat system. The Go files in this directory are: auth.go, channel.go, chat.go, tenant.go, and user.go

The auth.go file handles user registration and login by validating credentials, checking user existence in the database, verifying passwords with bcrypt, generating a JWT token, and returning the token and user information in the chat system.

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
`package controllers` `import (` `"net/http"` `"github.com/Tabintel/multi-tenant-chat/backend/db"` `"github.com/Tabintel/multi-tenant-chat/backend/models"` `"github.com/Tabintel/multi-tenant-chat/backend/utils"` `"github.com/gin-gonic/gin"` `"golang.org/x/crypto/bcrypt"` `)` `// Login authenticates a user and returns a JWT`* `func Login(c *gin.Context) {` `var req struct {` `` Email string `json:"email" binding:"required"` `` `` Password string `json:"password" binding:"required"` `` `}` `if err := c.ShouldBindJSON(&req); err != nil {` `c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})` `return` `}` `var user models.User` `if err := db.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})` `return` `}` `if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})` `return` `}` `token, err := utils.GenerateToken(user)` `if err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})` `return` `}` `c.JSON(http.StatusOK, gin.H{"token": token, "user": user})` `}`
  1. Stream Chat Integration

The chat.go file in backend/controllers/ integrates the Go backend with Stream's chat service, enabling seamless chat functionality. When you initiate a chat session, the system retrieves your user ID from the JWT token, generates a Stream-specific token for secure access to Stream's chat features, and sends this token to the frontend for client-side chat operations.

The flow is:

  • The backend gets the user ID from the JWT token.
  • It generates a Stream token.
  • It returns the token to the frontend.
go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
`package controllers` `import (` `"net/http"` `"github.com/Tabintel/multi-tenant-chat/backend/services"` `"github.com/Tabintel/multi-tenant-chat/backend/utils"` `"github.com/gin-gonic/gin"` `)` `// GetChatToken generates a Stream Chat token` `func GetChatToken(c *gin.Context) {` `// Get user ID from JWT` `userID, err := utils.GetUserIDFromContext(c)` `if err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})` `return` `}` `// Generate Stream token` `token, err := services.GenerateStreamToken(userID)` `if err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate chat token"})` `return` `}` `// Return token to frontend` `c.JSON(http.StatusOK, gin.H{"token": token})` `}`
  1. Tenant Management

tenant.go in the backend/controllers directory allows you to create and manage organizations (tenants) that isolate users and their chat activities. When you create a new tenant, the system validates your request, creates a tenant record, saves it to the database, and returns the tenant details.

It also organizes users (managed in the next step with user.go) and channels (handled later in channel.go) under a specific organization. Without a tenant, users and channels cannot be properly associated, making this the starting point for the system.

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
`package controllers` `import (` `"net/http"` `"github.com/Tabintel/multi-tenant-chat/backend/db"` `"github.com/Tabintel/multi-tenant-chat/backend/models"` `"github.com/gin-gonic/gin"` `)` `func CreateTenant(c *gin.Context) {` `var req struct {` `` Name string `json:"name" binding:"required"` `` `}` `if err := c.ShouldBindJSON(&req); err != nil {` `c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})` `return` `}` `tenant := models.Tenant{Name: req.Name}` `if err := db.DB.Create(&tenant).Error; err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create tenant"})` `return` `}` `c.JSON(http.StatusCreated, tenant)` `}` `func ListTenants(c *gin.Context) {` `var tenants []models.Tenant` `if err := db.DB.Find(&tenants).Error; err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch tenants"})` `return` `}` `c.JSON(http.StatusOK, tenants)` `}`

This also connects to the authentication system in auth.go, as only authenticated users can create tenants, and then create channels using the Stream chat.

  1. User Management

handlers/tenant\_user.go manages users within a specific tenant in the chat system. When you create a new user, the system validates your input, hashes the password using bcrypt for security, creates a user record in the database, and registers the user with Stream’s chat service (building on the integration from chat.go). The user is linked to a tenant (created via tenant.go), ensuring chats are within the correct organization’s context.

For instance, after creating a tenant, you can add users to it. These users can then participate in chat channels (managed in channel.go) under that tenant. This connection ensures that users are securely authenticated (via auth.go) and properly integrated with Stream’s Chat functionality.

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
`package controllers` `import (` `"net/http"` `"github.com/Tabintel/multi-tenant-chat/backend/db"` `"github.com/Tabintel/multi-tenant-chat/backend/models"` `"github.com/Tabintel/multi-tenant-chat/backend/services"` `"github.com/gin-gonic/gin"` `"golang.org/x/crypto/bcrypt"` `)` `func CreateUser(c *gin.Context) {` `var req struct {` `` Name string `json:"name" binding:"required"` `` `` Email string `json:"email" binding:"required"` `` `` Password string `json:"password" binding:"required"` `` `` Role models.Role `json:"role" binding:"required"` `` `` TenantID string `json:"tenant_id" binding:"required"` `` `}` `if err := c.ShouldBindJSON(&req); err != nil {` `c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})` `return` `}` `hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)` `if err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not hash password"})` `return` `}` `user := models.User{` `Name: req.Name,` `Email: req.Email,` `Password: string(hash),` `Role: req.Role,` `TenantID: req.TenantID,` `}` `if err := db.DB.Create(&user).Error; err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create user"})` `return` `}` `if err := services.CreateStreamUser(user); err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Stream user creation failed"})` `return` `}` `c.JSON(http.StatusCreated, user)` `}`

This is just a snippet of the full implementation of the tenants.go. Access the complete code.

  1. Channel Management

The handlers/channel.go file allows you to create and manage chat channels where users within a tenant can communicate. When you create a new channel, the system validates the channel details, checks your permissions (using the user context from middleware/auth.go and handlers/tenant\_user.go), retrieves the tenant and user IDs (from handlers/tenant.go and handlers/tenant_user.go), creates the channel in Stream using the client initialized in services/stream.go, and saves the channel record with a StreamID for reference.

Channels are linked to a tenant, ensuring users communicate within their organization’s isolated environment.

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
`package controllers` `import (` `"net/http"` `"github.com/Tabintel/multi-tenant-chat/backend/models"` `"github.com/Tabintel/multi-tenant-chat/backend/services"` `"github.com/Tabintel/multi-tenant-chat/backend/utils"` `"github.com/gin-gonic/gin"` `)` `type CreateChannelRequest struct {` `` Name string `json:"name" binding:"required"` `` `` Description string `json:"description"` `` `}` *`// GetChannels returns all channels for the user's tenant`* `func GetChannels(c *gin.Context) {` `_, err := utils.GetUserIDFromContext(c)` `if err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})` `return` `}` `tenantID, err := utils.GetTenantIDFromContext(c)` `if err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})` `return` `}` `channels, err := services.GetTenantChannels(tenantID)` `if err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channels"})` `return` `}` `c.JSON(http.StatusOK, gin.H{"channels": channels})` `}` *`// CreateChannel creates a new channel`* `func CreateChannel(c *gin.Context) {` `var req CreateChannelRequest` `if err := c.ShouldBindJSON(&req); err != nil {` `c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})` `return` `}` `userID, err := utils.GetUserIDFromContext(c)` `if err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})` `return` `}` `tenantID, err := utils.GetTenantIDFromContext(c)` `if err != nil {` `c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})` `return` `}` `channel := models.Channel{` `Name: req.Name,` `Description: req.Description,` `TenantID: tenantID,` `CreatedBy: userID,` `}` `channelID, err := services.CreateStreamChannel(channel, userID)` `if err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create channel"})` `return` `}` `channel.ID = channelID` `// Save channel to DB` `if err := services.SaveChannel(channel); err != nil {` `c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save channel"})` `return` `}` `c.JSON(http.StatusCreated, gin.H{"channel": channel})` `}`

How It Works:

  • Imports: Uses github.com/gin-gonic/gin for HTTP handling, db for database operations, models for the Channel struct, and services to interact with Stream via CreateStreamChannel.
  • CreateChannel Function: Validates the request JSON (ensuring the name field is present), checks if the user has Admin or Moderator permissions (set during user creation in tenant\_user.go), retrieves the tenant and user IDs from the JWT claims (set by middleware/auth.go), creates a channel in Stream, and saves the channel locally with the StreamID returned by Stream.
  • Stream Integration: The services.CreateStreamChannel function uses the Stream client (from services/stream.go) to create a channel, assigning it to the tenant’s team ( tenantID-channelName), ensuring isolation. The returned streamChannelID links the record to Stream’s infrastructure.
  • Go Efficiency: Leverages Go’s goroutines (via Gin) for concurrent request handling, struct validation with binding:"required", and type-safe data modeling with the Channel struct.

This connects to controllers/chat.go (from earlier inputs), which provides Stream tokens for users to access channels on the frontend. For the full code, including ListChannels, see handlers/channel.go.

This Go controller manages channel-related HTTP endpoints:

  • GetChannels: Extracts user/tenant IDs from context, calls services to fetch channels, returns JSON response.
  • CreateChannel: Parses channel creation requests, validates user permissions, creates channel via services, and returns channel data.
  • Uses utility functions to extract authentication context.
  1. Bring It All Together in the Entry Point

Now that you’ve set up the core components—Stream integration, authentication, tenants, users, and channels—it’s time to tie everything together. In every Go application, the main.go file is where the app starts, and for this multi-tenant chat system, it’s located at cmd/main.go. This file connects to the database, sets up the Stream client, and configures the API routes to handle requests for all the features you’ve built.

Learn more about standard Go project layouts.

The file also initializes the database (Postgres with Neon serverless), sets up the Stream client, and configures the Gin router for handling HTTP requests.

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
`package main` `import (` `"log"` `"os"` `"github.com/Tabintel/multi-tenant-chat/backend/handlers"` `"github.com/Tabintel/multi-tenant-chat/backend/middleware"` `"github.com/Tabintel/multi-tenant-chat/backend/db"` `"github.com/Tabintel/multi-tenant-chat/backend/models"` `_ "github.com/Tabintel/multi-tenant-chat/backend/docs"` `"github.com/gin-gonic/gin"` `"github.com/joho/godotenv"` `ginSwagger "github.com/swaggo/gin-swagger"` `swaggerFiles "github.com/swaggo/files"` `"github.com/gin-contrib/cors"` `)` *`// @title Multi-Tenant Chat API`* *`// @version 1.0`* *`// @description API documentation for the Go + Stream multi-tenant chat system.`* *`// @host localhost:8080`* *`// @BasePath /`* `func main() {` `// Load environment variables` `if err := godotenv.Load(); err != nil {` `log.Println("No .env file found, relying on environment variables")` `}` `// Connect to PostgreSQL or use mock mode` `db.Connect()` `// Configure CORS to allow Authorization header` `config := cors.DefaultConfig()` `config.AllowOrigins = []string{"http://localhost:3000"}` `config.AllowHeaders = []string{"Content-Type", "Authorization"}` `config.AllowCredentials = true` `r := gin.Default()` `r.Use(cors.New(config))` `// Swagger docs endpoint` `r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))` `// Auth endpoints` `r.POST("/auth/login", handlers.Login)` `r.POST("/auth/register", handlers.Register)` `// Stream Chat token endpoint (protected)` `r.GET("/stream/token", middleware.JWTAuth(), handlers.StreamToken)` `// Tenant endpoints` `r.POST("/tenants", middleware.JWTAuth(), middleware.RequireRole(string(models.RoleAdmin)), handlers.CreateTenant)` `// Make GET /tenants public for login/signup dropdown` `r.GET("/tenants", handlers.ListTenants)` `// User endpoints (Admin/Moderator for create/update, Admin for delete, all roles for list)` `r.POST("/users", middleware.JWTAuth(), middleware.RequireRole(string(models.RoleAdmin), string(models.RoleModerator)), handlers.CreateUser)` `r.GET("/users", middleware.JWTAuth(), handlers.ListUsers)` `r.PUT("/users/:id", middleware.JWTAuth(), middleware.RequireRole(string(models.RoleAdmin), string(models.RoleModerator)), handlers.UpdateUser)` `r.DELETE("/users/:id", middleware.JWTAuth(), middleware.RequireRole(string(models.RoleAdmin)), handlers.DeleteUser)` `// Channel endpoints (Admin/Moderator for create, all roles for list)` `r.POST("/channels", middleware.JWTAuth(), middleware.RequireRole(string(models.RoleAdmin), string(models.RoleModerator)), handlers.CreateChannel)` `r.GET("/channels", middleware.JWTAuth(), handlers.ListChannels)` `// Messages endpoint (all authenticated users)` `r.POST("/messages", middleware.JWTAuth(), handlers.SendMessage)` `r.GET("/messages/:stream_id", middleware.JWTAuth(), handlers.GetMessages)` `port := os.Getenv("PORT")` `if port == "" {` `port = "8080"` `}` `log.Printf("Starting server on :%s", port)` `r.Run(":" + port)` `}`

As shown in the code above, there are:

  • Imports: gin-gonic/gin for routing, stream-go2/v7 for Stream integration, and godotenv to load environment variables.
  • Database Initialization: db.InitDB() connects to the Neon PostgreSQL instance.
  • Stream Client: The stream.NewClient function initializes the Stream SDK with your API key and secret.
  • Routing: controllers.SetupRoutes wires up the API endpoints for the chat system.

Go’s lightweight nature ensures fast startup, and its static typing catches errors early.

Setting up Stream for the Multi-Chat System

Before you test the multi-tenant chat system, you need to set up Stream, which will power the real-time messaging features. Stream makes it easy to add chat functionality without building everything from scratch. Let’s walk through the setup process step by step, from creating an account to configuring the roles your app will use.

First, go to https://getstream.io/.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Create your account with your email, Google, or GitHub.

Enter your role, industry, app user count, technology, and the Stream products you want to integrate. For this project, we are starting with Chat Messaging; however, you can select all the products, as we will add more features later.

Then, click on Complete Signup.

You will then be redirected to your Stream dashboard.

Enter your organization’s name, email, and website to create your organization.

To begin the integration process, you can create a new application, get the App ID, and save it to your .env in the project directory.

To view details of the app, click on the app name, then navigate to Chat Overview.

For the multi-tenant chat system we are building, there are four roles for users:

  • Admin
  • Moderator
  • Member
  • Guest

To add a new role and scope, click on Roles & Permissions, then Add New Role, and add the roles listed above.

You can select the dropdown from the menu as shown below.

This sets up Stream Chat from the web interface. On the programming side, to use Stream Chat, you have to install the Go SDK provided by Stream. This will handle server-side actions like generating user tokens, managing channels, roles, and sending messages.

Install with this command in your terminal:

go
1
go get github.com/GetStream/stream-chat-go

In the Multi-chat system, user tokens are generated using the Go SDK. To learn more about this, open the Go SDK documentation. Navigate to Tokens & Authentication in the left side bar, and scroll down to Manually Generating Tokens.

If you need further assistance, you can use the Ask AI feature at the top of the Stream documentation page.

How Creating a Channel with Stream and Go Works in the Application

After signing up for the chat system, new tenants are added as follows.

You can test creating a channel with Stream using the channels API endpoint built with Go. To do this, make a POST request to the http://localhost:8080/channels endpoint with the payload as shown in the image:

Note: Ensure you add an Authorization bearer token, which you get when you sign up and when you log in.

The API request and response to create a channel with Stream can be tested using Thunder Client.

After sending the request, verify that the channel is created on the Stream dashboard and the database.

You can view it through the Chat Explorer on the Stream dashboard.

Database

Sending Messages with Stream and Go

The Go backend uses services.GetStreamClient() to initialize the Stream Go SDK with your API keys. All message operations are performed directly against Stream, not the local database.

You can also check the Chat Logs to confirm messages sent with Stream through the Go SDK and the .env file.

STREAM_API_KEY=your_stream_api_key
STREAM_API_SECRET=your_stream_api_secret

We can test the messages endpoint by making a POST request to http://localhost:8080/messages

You can also confirm that the messages are being sent through Stream Chat Logs on the dashboard, like so:

For the multi-chat login, select a particular organization from the dropdown menu shown below. In this context, the login is for Tenant A Corp.

When you log in, it redirects to the chat dashboard like so:

You can send a message and click on other chat channels like #engineering to view the messages in that channel.

Note: This chat dashboard is specific to a particular tenant (organization). As mentioned earlier, this chat is for Tenant A.

Let’s try another Tenant/Organization as shown in this GIF.

Running the Backend and Frontend Locally

First, clone the GitHub repository with this command:

bash
1
git clone https://github.com/Tabintel/multi-tenant-chat.git

Then navigate to the backend and install dependencies

bash
1
2
cd backend go mod tidy

Set up your .env file in the backend/cmd directory and run the Go backend with

bash
1
go run cmd/main.go

The server will run on http://localhost:8080 and the Swagger API documentation on http://localhost:8080/swagger/index.html, which you can access in your browser.

Once the backend server is running, you can access the API docs, which are created with Swagger and OpenAPI spec at: http://localhost:8080/swagger/index.html

Use it to explore and test the API endpoints.

For the frontend, navigate to the directory and install dependencies with:
cd frontend
npm install

Also, set up your .env. local and then run the application
npm run dev

The web application will run on http://localhost:3000 in your browser.

Next Steps

And that’s it. You've learned how to build a functional multi-chat system with Go and Stream.

From here, you can continue exploring by forking or cloning the GitHub repository and experimenting with advanced chat features, such as adding context-aware AI responses or real-time language translation.

Looking forward to your Pull Request. ✨

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->