Vibe coding with AI course ยท Module 9: Discord Clone

Servers and Channels - Community Structure

10 min read
In this lesson6

Welcome! The Black Pearl guild already has a hundred members, and all communication happens in a single chat. The captain's announcements get lost among jokes, voyage plans mix with trading, and nobody knows who is in charge. Time to build Pirate's Cove, a platform for crews modeled on Discord, where every guild has its own server with organized channels and roles.

What you will learn:

  • describe a server, categories, channels, roles and members in TypeScript,
  • build a channel sidebar with collapsible categories,
  • make a server list that marks the active one,
  • group the member list by roles and status.

Discord versus Slack

Slack is a workspace for companies and teams: work conversations, threads and asynchronous communication. Discord was created for communities and gamers: servers, always-open voice channels, screen sharing, roles and emotes. Pirate's Cove follows Discord's path.

Server structure

A server contains categories, channels, roles, members and emojis. The hierarchy from the broadest: server (Black Pearl Guild), category (TEXT CHANNELS), channel (#general) and the member who writes in it:

interface Server {
  id: string;
  name: string; // "Black Pearl Guild"
  icon: string;
  banner?: string;

  owner: string; // user ID
  members: Member[];

  categories: Category[];
  channels: Channel[];

  roles: Role[];
  emojis: Emoji[];

  createdAt: Date;
}

interface Category {
  id: string;
  name: string; // "TEXT CHANNELS", "VOICE CHANNELS"
  position: number;
  channels: string[]; // channel IDs
}

interface Channel {
  id: string;
  serverId: string;
  categoryId: string;

  name: string;
  type: 'text' | 'voice' | 'announcement';
  topic?: string;

  position: number;
  isNSFW: boolean;

  // Permissions
  permissions: ChannelPermission[];

  // Voice specific
  userLimit?: number; // max users in voice channel
  bitrate?: number;

  createdAt: Date;
}

interface ChannelPermission {
  roleId: string;
  allow: Permission[];
  deny: Permission[];
}

type Permission =
  | 'VIEW_CHANNEL'
  | 'SEND_MESSAGES'
  | 'CONNECT' // voice
  | 'SPEAK' // voice
  | 'MANAGE_MESSAGES'
  | 'MANAGE_CHANNELS';

Categories group channels by topic, for example TEXT CHANNELS and VOICE CHANNELS. The channel type is a union, type: 'text' | 'voice' | 'announcement', so TypeScript won't let any other value through. The userLimit and bitrate fields only make sense in voice channels, which is why they are optional. In Discord's real API a category is also a kind of channel, and a channel points to it with the parent_id field; our model with a separate Category type is simpler to learn.

Roles and members

Roles decide the color of a name, the permissions and the place on the member list:

interface Role {
  id: string;
  serverId: string;

  name: string;
  color: string; // hex color for role badge
  position: number; // hierarchy

  permissions: Permission[];

  isHoisted: boolean; // show separately in member list
  isMentionable: boolean; // can be @mentioned

  memberCount: number;
  createdAt: Date;
}

interface Member {
  userId: string;
  user: {
    id: string;
    username: string;
    discriminator: string; // "#1234"
    avatar: string;
    banner?: string;
    status: 'online' | 'idle' | 'dnd' | 'offline';
    customStatus?: string;
  };

  // Server-specific
  nickname?: string; // override username
  roles: string[]; // role IDs
  joinedAt: Date;

  // Voice state
  voiceChannelId?: string;
  isMuted: boolean;
  isDeafened: boolean;
}

// Helper to get highest role
function getHighestRole(member: Member, roles: Role[]): Role | null {
  const memberRoles = roles
    .filter(r => member.roles.includes(r.id))
    .sort((a, b) => b.position - a.position);

  return memberRoles[0] || null;
}

color sets the color (hex) of the role badge in the member list, and position sets the hierarchy: the higher the number, the higher the role. isHoisted displays the members of that role separately in the member list. A user's status takes one of four values: online, idle, dnd (do not disturb) or offline. You read the four-digit tag with user.discriminator; Discord itself started replacing these tags with unique usernames in 2023, but in our clone we keep the classic format.

getHighestRole works in three steps: it filters the roles that belong to the member (member.roles.includes(r.id)), sorts them by position in descending order and returns the first one, or null when the member has no role.

The server's channel sidebar

The left sidebar shows the server name, the categories with channels and the status of the logged-in pirate:

'use client';

import { useState } from 'react';

interface ServerSidebarProps {
  server: Server;
  currentUserId: string;
  onChannelSelect: (channelId: string) => void;
}

export default function ServerSidebar({
  server,
  currentUserId,
  onChannelSelect
}: ServerSidebarProps) {
  const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
  const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());

  const toggleCategory = (categoryId: string) => {
    const newCollapsed = new Set(collapsedCategories);
    if (newCollapsed.has(categoryId)) {
      newCollapsed.delete(categoryId);
    } else {
      newCollapsed.add(categoryId);
    }
    setCollapsedCategories(newCollapsed);
  };

  const handleChannelClick = (channelId: string) => {
    setActiveChannelId(channelId);
    onChannelSelect(channelId);
  };

  return (
    <div className="w-60 bg-gray-800 flex flex-col h-screen">
      {/* Server Header */}
      <div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 shadow-md hover:bg-gray-750 cursor-pointer">
        <h2 className="font-bold text-white">{server.name}</h2>
        <button className="text-gray-400 hover:text-white">โ–ผ</button>
      </div>

      {/* Channels */}
      <div className="flex-1 overflow-y-auto pt-4">
        {server.categories
          .sort((a, b) => a.position - b.position)
          .map(category => {
            const categoryChannels = server.channels
              .filter(ch => ch.categoryId === category.id)
              .sort((a, b) => a.position - b.position);

            const isCollapsed = collapsedCategories.has(category.id);

            return (
              <div key={category.id} className="mb-4">
                {/* Category Header */}
                <button
                  onClick={() => toggleCategory(category.id)}
                  className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
                >
                  <span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>
                    โ–ถ
                  </span>
                  {category.name}
                </button>

                {/* Channels */}
                {!isCollapsed && (
                  <div className="mt-1">
                    {categoryChannels.map(channel => (
                      <button
                        key={channel.id}
                        onClick={() => handleChannelClick(channel.id)}
                        className={`w-full px-2 py-1 mx-2 rounded flex items-center gap-2 text-gray-400 hover:bg-gray-700 hover:text-gray-300 ${
                          activeChannelId === channel.id
                            ? 'bg-gray-700 text-white'
                            : ''
                        }`}
                      >
                        {channel.type === 'text' && <span className="text-gray-500">#</span>}
                        {channel.type === 'voice' && <span className="text-gray-500">[voice]</span>}
                        {channel.type === 'announcement' && <span className="text-gray-500">[news]</span>}
                        <span className="text-sm flex-1 text-left truncate">
                          {channel.name}
                        </span>
                        {channel.isNSFW && (
                          <span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
                        )}
                      </button>
                    ))}
                  </div>
                )}
              </div>
            );
          })}
      </div>

      {/* Voice Status */}
      <div className="bg-gray-900 p-2">
        {(() => {
          const currentMember = server.members.find(m => m.userId === currentUserId);
          if (!currentMember) return null;

          return (
            <div className="flex items-center gap-2 bg-gray-800 rounded p-2">
              <div className="relative">
                <img
                  src={currentMember.user.avatar}
                  alt={currentMember.user.username}
                  className="w-8 h-8 rounded-full"
                />
                <div
                  className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
                    currentMember.user.status === 'online' ? 'bg-green-500' :
                    currentMember.user.status === 'idle' ? 'bg-yellow-500' :
                    currentMember.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
                  }`}
                />
              </div>
              <div className="flex-1 min-w-0">
                <div className="text-white text-sm font-semibold truncate">
                  {currentMember.nickname || currentMember.user.username}
                </div>
                <div className="text-gray-400 text-xs truncate">
                  {currentMember.user.customStatus || `#${currentMember.user.discriminator}`}
                </div>
              </div>
              <div className="flex gap-1">
                <button className="p-1.5 hover:bg-gray-700 rounded">Mic</button>
                <button className="p-1.5 hover:bg-gray-700 rounded">Headphones</button>
                <button className="p-1.5 hover:bg-gray-700 rounded">Settings</button>
              </div>
            </div>
          );
        })()}
      </div>
    </div>
  );
}

The server name is displayed by the {server.name} expression. We keep collapsed categories in a Set: clicking a header creates a copy of the set, adds or removes the category id and saves the copy in state. Categories and channels are sorted by the position field, and the label before a name depends on the channel type.

Server list

A narrow bar on the left switches servers:

'use client';

interface ServerListProps {
  servers: Server[];
  activeServerId: string;
  onServerSelect: (serverId: string) => void;
  onCreateServer: () => void;
}

export default function ServerList({
  servers,
  activeServerId,
  onServerSelect,
  onCreateServer
}: ServerListProps) {
  return (
    <div className="w-18 bg-gray-900 flex flex-col items-center py-3 gap-2 h-screen overflow-y-auto">
      {/* Home Button */}
      <button className="w-12 h-12 bg-gray-700 hover:bg-blue-600 rounded-full flex items-center justify-center text-white transition-all hover:rounded-2xl">
        Home
      </button>

      <div className="w-8 h-0.5 bg-gray-700 rounded" />

      {/* Servers */}
      {servers.map(server => (
        <button
          key={server.id}
          onClick={() => onServerSelect(server.id)}
          className={`w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all hover:rounded-2xl relative ${
            activeServerId === server.id
              ? 'rounded-2xl'
              : ''
          }`}
          style={{
            backgroundColor: server.icon ? 'transparent' : '#5865F2'
          }}
        >
          {server.icon ? (
            <img src={server.icon} alt={server.name} className="w-full h-full rounded-full hover:rounded-2xl" />
          ) : (
            <span className="text-white font-bold">
              {server.name.charAt(0).toUpperCase()}
            </span>
          )}

          {/* Active Indicator */}
          {activeServerId === server.id && (
            <div className="absolute left-0 w-1 h-10 bg-white rounded-r" />
          )}
        </button>
      ))}

      {/* Add Server */}
      <button
        onClick={onCreateServer}
        className="w-12 h-12 bg-gray-700 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-2xl transition-all hover:rounded-2xl"
      >
        +
      </button>
    </div>
  );
}

The active server has a rounded square instead of a circle and a white bar at the edge, and a server without an icon shows the first letter of its name. The component keeps no state of its own; it only reports the choice through onServerSelect.

Member list

The right sidebar groups members. An offline member goes to the OFFLINE group, a member whose highest role is marked isHoisted goes to that role's group, and everyone else goes to ONLINE:

interface MemberListProps {
  server: Server;
  onMemberClick: (memberId: string) => void;
}

export default function MemberList({ server, onMemberClick }: MemberListProps) {
  // Group members by highest role
  const membersByRole = new Map<string, Member[]>();

  // Add "Online" and "Offline" default groups
  membersByRole.set('online', []);
  membersByRole.set('offline', []);

  server.members.forEach(member => {
    const highestRole = getHighestRole(member, server.roles);

    if (member.user.status === 'offline') {
      membersByRole.get('offline')!.push(member);
    } else if (highestRole && highestRole.isHoisted) {
      if (!membersByRole.has(highestRole.id)) {
        membersByRole.set(highestRole.id, []);
      }
      membersByRole.get(highestRole.id)!.push(member);
    } else {
      membersByRole.get('online')!.push(member);
    }
  });

  return (
    <div className="w-60 bg-gray-800 h-screen overflow-y-auto">
      <div className="p-4">
        {Array.from(membersByRole.entries()).map(([roleId, members]) => {
          if (members.length === 0) return null;

          const role = server.roles.find(r => r.id === roleId);
          const groupName = roleId === 'online' ? 'ONLINE' :
                           roleId === 'offline' ? 'OFFLINE' :
                           role?.name.toUpperCase() || 'MEMBERS';

          const groupColor = role?.color;

          return (
            <div key={roleId} className="mb-4">
              <div
                className="text-xs font-semibold mb-2 px-2"
                style={{ color: groupColor || '#96989D' }}
              >
                {groupName} - {members.length}
              </div>

              <div>
                {members.map(member => {
                  const displayName = member.nickname || member.user.username;
                  const memberRole = getHighestRole(member, server.roles);

                  return (
                    <button
                      key={member.userId}
                      onClick={() => onMemberClick(member.userId)}
                      className="w-full flex items-center gap-2 px-2 py-1 hover:bg-gray-700 rounded group"
                    >
                      <div className="relative">
                        <img
                          src={member.user.avatar}
                          alt={displayName}
                          className="w-8 h-8 rounded-full"
                        />
                        <div
                          className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-gray-800 ${
                            member.user.status === 'online' ? 'bg-green-500' :
                            member.user.status === 'idle' ? 'bg-yellow-500' :
                            member.user.status === 'dnd' ? 'bg-red-500' : 'bg-gray-500'
                          }`}
                        />
                      </div>

                      <div className="flex-1 min-w-0 text-left">
                        <div
                          className="text-sm font-medium truncate"
                          style={{
                            color: memberRole?.color || '#DCDDDE'
                          }}
                        >
                          {displayName}
                        </div>
                        {member.user.customStatus && (
                          <div className="text-xs text-gray-400 truncate">
                            {member.user.customStatus}
                          </div>
                        )}
                      </div>
                    </button>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

A Map remembers insertion order, and the online and offline groups are added to it first. In Discord the list looks different: hoisted roles first (for example ADMIN), then ONLINE, and OFFLINE at the end. It's enough to sort the groups before rendering:

const groupOrder = (id: string) => (id === 'offline' ? 2 : id === 'online' ? 1 : 0);

const groups = Array.from(membersByRole.entries())
  .sort(([a], [b]) => groupOrder(a) - groupOrder(b));

In JSX you then map over groups instead of Array.from(membersByRole.entries()). The assignment of members to groups doesn't change; only the display order does.

Captain Redbeard's tip: before you ask AI for more views, paste in the Server, Channel, Role and Member interfaces. Well-named types are like a map: with every new component you know where to drop anchor.

Remember: a server consists of categories, categories of channels, and roles decide who appears on the list and how. In the editor below you have the channel sidebar of the Black Pearl guild: collapse a category and switch channels, then solve the tasks.

Code for this lesson: App.jsx
// Server Sidebar with Categories
import { useState } from 'react';

const server = {
  name: 'Black Pearl Guild',
  icon: '',
  categories: [
    {
      id: 1,
      name: 'TEXT CHANNELS',
      channels: [
        { id: 1, name: 'general', type: 'text', isNSFW: false },
        { id: 2, name: 'treasure-hunts', type: 'text', isNSFW: false },
        { id: 3, name: 'ship-repairs', type: 'text', isNSFW: false }
      ]
    },
    {
      id: 2,
      name: 'VOICE CHANNELS',
      channels: [
        { id: 4, name: 'Battle Coordination', type: 'voice' },
        { id: 5, name: 'Crew Hangout', type: 'voice' }
      ]
    }
  ]
};

export default function App() {
  const [activeChannelId, setActiveChannelId] = useState(1);
  const [collapsedCategories, setCollapsedCategories] = useState(new Set());

  const toggleCategory = (categoryId) => {
    const newCollapsed = new Set(collapsedCategories);
    if (newCollapsed.has(categoryId)) {
      newCollapsed.delete(categoryId);
    } else {
      newCollapsed.add(categoryId);
    }
    setCollapsedCategories(newCollapsed);
  };

  return (
    <div className="flex h-screen bg-gray-100">
      {/* Server Sidebar */}
      <div className="w-60 bg-gray-800 flex flex-col">
        {/* Server Header */}
        <div className="h-12 px-4 flex items-center justify-between border-b border-gray-900 hover:bg-gray-750 cursor-pointer">
          <h2 className="font-bold text-white">{server.name}</h2>
          <button className="text-gray-400 hover:text-white">โ–ผ</button>
        </div>

        {/* Channels */}
        <div className="flex-1 overflow-y-auto pt-4">
          {server.categories.map(category => {
            const isCollapsed = collapsedCategories.has(category.id);

            return (
              <div key={category.id} className="mb-4">
                {/* Category Header */}
                <button
                  onClick={() => toggleCategory(category.id)}
                  className="w-full px-2 py-1 flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase hover:text-gray-300"
                >
                  <span className={`transform transition ${isCollapsed ? '' : 'rotate-90'}`}>โ–ถ</span>
                  {category.name}
                </button>

                {/* Channels */}
                {!isCollapsed && (
                  <div className="mt-1">
                    {category.channels.map(channel => (
                      <button
                        key={channel.id}
                        onClick={() => setActiveChannelId(channel.id)}
                        className={`w-full px-2 py-1 mx-2 rounded flex items-center gap-2 text-gray-400 hover:bg-gray-700 hover:text-gray-300 ${
                          activeChannelId === channel.id ? 'bg-gray-700 text-white' : ''
                        }`}
                      >
                        {channel.type === 'text' && <span className="text-gray-500">#</span>}
                        {channel.type === 'voice' && <span className="text-gray-500">[voice]</span>}
                        <span className="text-sm flex-1 text-left truncate">{channel.name}</span>
                        {channel.isNSFW && (
                          <span className="text-xs bg-red-500 px-1 rounded">NSFW</span>
                        )}
                      </button>
                    ))}
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {/* Voice Status */}
        <div className="bg-gray-900 p-2">
          <div className="flex items-center gap-2 bg-gray-800 rounded p-2">
            <div className="text-3xl">โ—†</div>
            <div className="flex-1">
              <div className="text-white text-sm font-semibold">Captain Redbeard</div>
              <div className="text-gray-400 text-xs">Online</div>
            </div>
            <div className="flex gap-1">
              <button className="p-1.5 hover:bg-gray-700 rounded">Mic</button>
              <button className="p-1.5 hover:bg-gray-700 rounded">Headphones</button>
              <button className="p-1.5 hover:bg-gray-700 rounded">Settings</button>
            </div>
          </div>
        </div>
      </div>

      {/* Main Area */}
      <div className="flex-1 flex items-center justify-center text-gray-500">
        Select a channel to view
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does a Server in Discord contain?

  2. 2. What channel types does Discord have?

These are 2 of 7 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the Server structure hierarchy (from widest):

  • Code editor

    Discord server navigation

  • Click in order

    Click to build the type definition of the channel.type field:

  • Code editor

    Server switcher sidebar

  • Horizontal ordering

    Arrange the type definition line: type: text | voice

  • Vertical ordering

    Arrange the groups in the member list (MemberList) in display order:

  • Horizontal ordering

    Arrange access to the four-digit user tag: user.discriminator

  • Click in order

    Click to build the JSX expression that displays the server name:

Useful articles