# How to Build a Messaging Platform with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Core or higher
- Last updated: April 2026

## TL;DR

Build a channel-based messaging platform in Replit in 2-4 hours. Use Replit Agent to generate an Express + PostgreSQL app with workspace channels, real-time delivery via Server-Sent Events and PostgreSQL LISTEN/NOTIFY, thread replies, emoji reactions, and unread badge counts. Deploy on Reserved VM for always-on connections.

## Before you start

- A Replit Core account or higher (Reserved VM is required for SSE connections)
- Basic understanding of what a WebSocket or persistent HTTP connection is (no coding required)
- Optional: a list of channel names for your initial workspace
- Optional: design reference for the three-panel chat layout (Slack's layout is a good reference)

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Repl and use the Agent prompt below to generate the full messaging platform structure. This is a complex build — the Agent prompt is detailed to ensure the real-time architecture is correct from the start.

```
// Type this into Replit Agent:
// Build a channel-based messaging platform with Express and PostgreSQL using Drizzle ORM.
// Tables:
// - workspaces: id serial pk, name text not null, owner_id text not null, created_at timestamp
// - workspace_members: id serial pk, workspace_id integer FK workspaces, user_id text not null,
//   role text default 'member' (enum: owner/admin/member), display_name text,
//   avatar_url text, joined_at timestamp, unique(workspace_id, user_id)
// - channels: id serial pk, workspace_id integer FK workspaces, name text not null,
//   type text default 'public' (enum: public/private/direct), description text,
//   created_by text not null, created_at timestamp
// - channel_members: id serial pk, channel_id integer FK channels, user_id text not null,
//   last_read_at timestamp default now(), unique(channel_id, user_id)
// - messages: id serial pk, channel_id integer FK channels, sender_id text not null,
//   content text not null, type text default 'text' (enum: text/image/file),
//   file_url text, parent_id integer FK messages (null for top-level),
//   edited_at timestamp, created_at timestamp default now()
// - reactions: id serial pk, message_id integer FK messages, user_id text not null,
//   emoji text not null, unique(message_id, user_id, emoji)
// Create a PostgreSQL trigger: after INSERT on messages, fire
// pg_notify('new_message', row_to_json(NEW)::text).
// Routes: POST /api/workspaces, POST /api/workspaces/:id/invite,
// POST /api/workspaces/:id/join, GET /api/workspaces/:id/channels,
// POST /api/channels, GET /api/channels/:id/messages,
// POST /api/channels/:id/messages, PATCH /api/messages/:id,
// DELETE /api/messages/:id, POST /api/messages/:id/reactions,
// PATCH /api/channels/:id/read, GET /api/channels/:id/stream (SSE).
// React frontend with 3-panel layout: workspace sidebar, channel list with unread badges,
// message list with reactions. Use Replit Auth. Bind server to 0.0.0.0.
```

> Pro tip: After Agent creates the schema, verify the LISTEN/NOTIFY trigger exists by running SELECT tgname FROM pg_trigger in the Replit SQL Editor. You should see the trigger on the messages table.

**Expected result:** A running Express app with all six tables, the NOTIFY trigger, and a three-panel React frontend. Opening two browser tabs shows the interface.

### 2. Build the SSE real-time message stream

The SSE endpoint holds the HTTP connection open and forwards PostgreSQL NOTIFY events to the client. Each client subscribes to a specific channel. When a message is inserted anywhere in that channel, the trigger fires pg_notify and all connected clients receive it instantly.

```
const { Pool } = require('pg');

// Dedicated connection for LISTEN/NOTIFY (cannot use the regular pool)
const listenClient = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,
});

// Map of channel subscriptions: channelId -> Set of SSE response objects
const subscriptions = new Map();

// Initialize LISTEN once on startup
async function initNotifyListener() {
  const client = await listenClient.connect();
  await client.query('LISTEN new_message');

  client.on('notification', (msg) => {
    try {
      const message = JSON.parse(msg.payload);
      const channelId = message.channel_id;
      const subscribers = subscriptions.get(channelId);
      if (subscribers) {
        const data = `data: ${JSON.stringify(message)}\n\n`;
        subscribers.forEach(res => {
          try { res.write(data); } catch (e) { /* client disconnected */ }
        });
      }
    } catch (err) {
      console.error('NOTIFY parse error:', err.message);
    }
  });

  client.on('error', (err) => {
    console.error('LISTEN client error:', err.message);
    // Reconnect after 5 seconds
    setTimeout(initNotifyListener, 5000);
  });
}

initNotifyListener();

// GET /api/channels/:id/stream — SSE endpoint
router.get('/:id/stream', async (req, res) => {
  const channelId = parseInt(req.params.id);

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.write('data: {"type":"connected"}\n\n');

  // Add to subscriptions
  if (!subscriptions.has(channelId)) subscriptions.set(channelId, new Set());
  subscriptions.get(channelId).add(res);

  // Cleanup on disconnect
  req.on('close', () => {
    subscriptions.get(channelId)?.delete(res);
    if (subscriptions.get(channelId)?.size === 0) subscriptions.delete(channelId);
  });
});
```

> Pro tip: LISTEN/NOTIFY requires a dedicated PostgreSQL connection that stays open permanently — you cannot use a connection pool for this. The listenClient pool with max: 1 ensures a single dedicated listening connection.

**Expected result:** Opening GET /api/channels/1/stream in a browser tab keeps the connection open. Inserting a message via POST /api/channels/1/messages immediately sends a data: event to all connected clients subscribed to channel 1.

### 3. Build message CRUD and the thread reply system

Messages support edit and delete for the sender. Thread replies use the parent_id column to link replies to their parent message. The message list route returns only top-level messages; thread replies are fetched separately.

```
const { messages, reactions } = require('../../shared/schema');
const { eq, isNull, and, desc } = require('drizzle-orm');

// GET /api/channels/:id/messages — cursor-based pagination
router.get('/:id/messages', async (req, res) => {
  const channelId = parseInt(req.params.id);
  const { before, limit = 50 } = req.query;

  const conditions = [
    eq(messages.channelId, channelId),
    isNull(messages.parentId), // Top-level only — threads fetched separately
  ];
  if (before) conditions.push(sql`${messages.id} < ${parseInt(before)}`);

  const rows = await db.select().from(messages)
    .where(and(...conditions))
    .orderBy(desc(messages.createdAt))
    .limit(parseInt(limit));

  // Get reaction counts for all messages
  const messageIds = rows.map(m => m.id);
  const reactionCounts = messageIds.length > 0 ? await db.execute(
    sql`SELECT message_id, emoji, COUNT(*) as count FROM reactions
        WHERE message_id = ANY(${messageIds}) GROUP BY message_id, emoji`
  ) : { rows: [] };

  // Attach reactions to messages
  const withReactions = rows.map(msg => ({
    ...msg,
    reactions: reactionCounts.rows.filter(r => r.message_id === msg.id),
  }));

  res.json(withReactions.reverse()); // Chronological order for the UI
});

// PATCH /api/messages/:id — edit own message
router.patch('/:id', async (req, res) => {
  const senderId = req.user?.id;
  const [updated] = await db.update(messages)
    .set({ content: req.body.content, editedAt: new Date() })
    .where(and(eq(messages.id, parseInt(req.params.id)), eq(messages.senderId, senderId)))
    .returning();
  if (!updated) return res.status(403).json({ error: 'Cannot edit this message' });
  res.json(updated);
});

// POST /api/messages/:id/reactions — add or remove emoji
router.post('/:id/reactions', async (req, res) => {
  const userId = req.user?.id;
  const messageId = parseInt(req.params.id);
  const { emoji } = req.body;

  const existing = await db.select().from(reactions)
    .where(and(
      eq(reactions.messageId, messageId),
      eq(reactions.userId, userId),
      eq(reactions.emoji, emoji)
    ));

  if (existing.length > 0) {
    await db.delete(reactions).where(eq(reactions.id, existing[0].id));
    return res.json({ added: false, emoji });
  }

  await db.insert(reactions).values({ messageId, userId, emoji });
  res.json({ added: true, emoji });
});
```

**Expected result:** GET /api/channels/1/messages returns the 50 most recent top-level messages with reaction arrays. Cursor-based pagination works by passing before=<lowest message id> from the current page.

### 4. Add unread counts and channel read tracking

Unread counts are calculated per channel member based on last_read_at. When a user opens a channel, update their last_read_at. The channel list response includes unread_count for each channel to power the badge UI.

```
// PATCH /api/channels/:id/read — mark channel as read
router.patch('/:id/read', async (req, res) => {
  const userId = req.user?.id;
  const channelId = parseInt(req.params.id);

  await db.update(channelMembers)
    .set({ lastReadAt: new Date() })
    .where(and(
      eq(channelMembers.channelId, channelId),
      eq(channelMembers.userId, userId)
    ));

  res.json({ ok: true });
});

// GET /api/workspaces/:id/channels — channel list with unread counts
router.get('/:workspaceId/channels', async (req, res) => {
  const userId = req.user?.id;
  const workspaceId = parseInt(req.params.workspaceId);

  const channelsWithUnread = await db.execute(sql`
    SELECT
      c.id, c.name, c.type, c.description,
      COALESCE(unread.count, 0) AS unread_count
    FROM channels c
    INNER JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = ${userId}
    LEFT JOIN LATERAL (
      SELECT COUNT(*) as count
      FROM messages m
      WHERE m.channel_id = c.id
        AND m.created_at > cm.last_read_at
        AND m.sender_id != ${userId}
        AND m.parent_id IS NULL
    ) unread ON true
    WHERE c.workspace_id = ${workspaceId}
    ORDER BY c.type, c.name ASC
  `);

  res.json(channelsWithUnread.rows);
});
```

> Pro tip: Update last_read_at whenever the user sends a message to that channel as well — a sender obviously has 'read' their own message. This prevents the unread count incrementing on the sender's own messages.

**Expected result:** GET /api/workspaces/1/channels returns channels with unread_count badges. Opening a channel and calling PATCH /api/channels/:id/read resets that channel's unread_count to 0.

### 5. Deploy on Reserved VM

Messaging requires Reserved VM. SSE connections are long-lived — they must stay open while users are active. Autoscale drops connections when instances scale to zero. Reserved VM ($10-20/month) keeps the process always running.

```
// server/index.js — complete setup for messaging platform
const express = require('express');
const path = require('path');
const { requireAuth } = require('@replit/repl-auth');

const channelsRouter = require('./routes/channels');
const messagesRouter = require('./routes/messages');
const workspacesRouter = require('./routes/workspaces');
const streamRouter = require('./routes/stream');

const app = express();
app.use(express.json());
app.use(requireAuth);

// SSE stream routes (no JSON body parsing needed)
app.use('/api/channels', streamRouter); // GET /:id/stream
app.use('/api/channels', messagesRouter);
app.use('/api/workspaces', workspacesRouter);
app.use('/api/workspaces', channelsRouter);

// Serve React frontend
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// IMPORTANT: bind to 0.0.0.0 for Replit
const server = app.listen(5000, '0.0.0.0', () => {
  console.log('Messaging platform running on port 5000');
});

// Keep SSE connections alive with longer timeout
server.keepAliveTimeout = 120000;
server.headersTimeout = 121000;
```

> Pro tip: To deploy on Reserved VM: click Deploy → Reserved VM → select the smallest VM size ($10/month). SSE connections need a persistent process — Reserved VM provides always-on Node.js without the cold starts of Autoscale.

**Expected result:** The app runs on Reserved VM. Multiple browser tabs can open SSE connections to /api/channels/:id/stream simultaneously. Messages sent in one tab appear in the other tab within milliseconds.

## Complete code example

File: `server/routes/stream.js`

```javascript
const express = require('express');
const { Pool } = require('pg');

const router = express.Router();

// Dedicated listening connection — cannot be pooled
const listenPool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
const subscriptions = new Map(); // channelId -> Set<res>

async function initListener() {
  const client = await listenPool.connect();
  await client.query('LISTEN new_message');
  console.log('PostgreSQL LISTEN active for new_message');

  client.on('notification', (msg) => {
    try {
      const message = JSON.parse(msg.payload);
      const subs = subscriptions.get(message.channel_id);
      if (subs && subs.size > 0) {
        const data = `data: ${JSON.stringify(message)}\n\n`;
        subs.forEach(res => { try { res.write(data); } catch (e) {} });
      }
    } catch (e) { console.error('NOTIFY parse error:', e.message); }
  });

  client.on('error', async (err) => {
    console.error('Listen client error:', err.message);
    client.release();
    setTimeout(initListener, 5000); // Reconnect after 5s
  });
}

initListener().catch(console.error);

// GET /api/channels/:id/stream
router.get('/:id/stream', (req, res) => {
  const channelId = parseInt(req.params.id);

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });

  // Send initial heartbeat
  res.write(`data: {"type":"connected","channelId":${channelId}}\n\n`);

  // Keep-alive ping every 30s to prevent connection timeout
  const pingInterval = setInterval(() => {
    try { res.write(': ping\n\n'); } catch (e) { clearInterval(pingInterval); }
  }, 30000);

  if (!subscriptions.has(channelId)) subscriptions.set(channelId, new Set());
  subscriptions.get(channelId).add(res);

  req.on('close', () => {
    clearInterval(pingInterval);
    subscriptions.get(channelId)?.delete(res);
    if (subscriptions.get(channelId)?.size === 0) subscriptions.delete(channelId);
  });
});

module.exports = router;
```

## Common mistakes

- **Deploying on Autoscale instead of Reserved VM** — Autoscale scales instances to zero during idle periods. When an instance shuts down, all active SSE connections are dropped and clients stop receiving messages. Fix: Deploy on Reserved VM. The persistent Node.js process keeps all SSE connections active and the LISTEN/NOTIFY client connected to PostgreSQL continuously.
- **Using a pooled connection for PostgreSQL LISTEN** — Connection pools recycle connections. When a LISTEN connection is returned to the pool, LISTEN state is lost. The next query on that connection won't receive notifications. Fix: Create a dedicated Pool with max: 1 for the LISTEN connection. Acquire it once with listenPool.connect() and never release it back to the pool.
- **Loading all messages on channel open without pagination** — A channel with 10,000 messages loads and renders all of them on open, causing slow first load and high memory usage on the client. Fix: Use cursor-based pagination: load the 50 most recent messages on open. When the user scrolls to the top, fetch the next 50 messages with before=<lowest message id>.
- **Not sending keep-alive pings on SSE connections** — Browsers and proxies close idle HTTP connections after 30-120 seconds. Without keep-alive pings, SSE connections disconnect even when no messages are being sent. Fix: Use setInterval to send a SSE comment (: ping) every 30 seconds on each active SSE connection. SSE comments are ignored by the client but keep the connection alive.

## Best practices

- Deploy on Reserved VM ($10-20/month) — SSE connections and LISTEN/NOTIFY both require a persistent, always-on Node.js process.
- Use a dedicated PostgreSQL connection for LISTEN/NOTIFY — never share it with the regular connection pool.
- Send keep-alive SSE pings every 30 seconds to prevent connection timeouts from browsers and proxies.
- Use cursor-based pagination for message history rather than page numbers — cursor pagination handles new messages arriving while the user reads without skipping or duplicating.
- Update last_read_at when users both open a channel and send a message — this prevents the unread badge from counting the sender's own messages.
- Store Replit Auth's user ID as sender_id in messages — this ensures edit and delete routes can verify ownership with a simple WHERE sender_id = req.user.id.
- Use the LATERAL subquery for unread counts in the channel list endpoint — it calculates all channel unread counts in a single database round-trip.

## Frequently asked questions

### Why use Server-Sent Events instead of WebSockets?

SSE is simpler to implement with Express and works natively over HTTP without upgrading the connection. It's one-directional (server to client), which is exactly what messaging needs for delivery. Clients send messages via regular POST requests; only receiving requires a persistent connection. WebSockets add complexity without benefit for this use case.

### What Replit plan do I need?

A paid plan (Core or higher) is required for Reserved VM deployment. Reserved VM ($10-20/month) is non-negotiable for a messaging platform — SSE connections and PostgreSQL LISTEN both require a persistent process that Autoscale cannot provide.

### How does the PostgreSQL LISTEN/NOTIFY real-time delivery work?

A PostgreSQL trigger fires pg_notify('new_message', row_to_json(NEW)::text) after every message INSERT. A dedicated Express connection listens on that channel with LISTEN new_message. When the notification arrives, the Node.js pg client fires an event, and the code forwards the message JSON to all SSE clients subscribed to that channel_id.

### How are unread message counts calculated?

The channel_members table stores a last_read_at timestamp per user per channel. The unread count query counts messages where created_at > last_read_at AND sender_id != current user. Opening a channel calls PATCH /api/channels/:id/read which updates last_read_at to now(), resetting the count to zero.

### Can I add file sharing to the messages?

Yes. Add a POST /api/channels/:id/upload route using Replit's built-in object storage (replit.nix has the storage SDK). After upload, insert a message with type = 'file' or 'image' and the file URL in the file_url column. The React frontend renders images inline and file messages as download cards.

### What happens if the LISTEN connection drops?

The pg client fires an error event. The error handler in initListener() releases the connection and calls setTimeout(initListener, 5000) to reconnect after 5 seconds. During the 5-second gap, any messages inserted will still be stored in the database — clients can refresh or poll to catch up.

### Can RapidDev help build a custom messaging platform?

Yes. RapidDev has built 600+ apps including real-time communication tools. They can add file sharing, advanced notification systems, video call integrations, and custom workspace management for your specific use case. Book a free consultation at rapidevelopers.com.

### How do I support multiple workspaces for a multi-tenant SaaS?

The schema already supports multiple workspaces. Each workspace has its own channels and members. Add a workspace selector in the sidebar. The workspace_members table controls access — users only see channels for workspaces they've joined. Invite links are workspace-scoped.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/messaging-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/messaging-platform
