Skip to main content

How to Build a Discord Bot with TypeScript 2026

·APIScout Team
Share:

Discord bots power communities, automate moderation, and create interactive experiences. This guide uses discord.js v14 with TypeScript to build a bot with slash commands, interactive buttons, and rich embeds.

TL;DR

discord.js v14 with TypeScript is the standard for Discord bot development in 2026. The shift to slash commands — mandatory since 2022, when Discord deprecated the old message prefix approach — means all bots need to register commands via the REST API before users can invoke them. Plan for up to one hour of propagation time when deploying global commands; use guild-scoped commands during development to get instant updates. For small bots running on fewer than 100 servers, the free tier on Railway or Fly.io is sufficient for a always-on deployment without managing infrastructure yourself.

What You'll Build

  • Slash commands (/ping, /info, /poll)
  • Rich embeds with formatting
  • Interactive buttons and select menus
  • Message handling and moderation
  • Command deployment to Discord

Prerequisites: Node.js 18+, TypeScript, Discord account.

The Discord Bot Ecosystem

discord.js is the dominant JavaScript and TypeScript library for Discord bot development, with over 40,000 GitHub stars and an active community that has tracked every major Discord API change since the library launched. The v14 release aligned the library closely with Discord's newer interaction-based architecture — slash commands, buttons, select menus, modals, and context menus all have first-class TypeScript support with narrow types that catch integration errors at compile time rather than at runtime. The vast majority of tutorials, StackOverflow answers, and community resources you'll find are written against discord.js, which makes the learning curve significantly smoother than alternatives.

For larger bots — multi-server community tools with dozens of commands, complex permission systems, and plugin architectures — the Sapphire Framework builds on top of discord.js and adds structure through decorators, automatic command loading, and a middleware-style precondition system. Sapphire is worth evaluating if you're building a bot that will be deployed across hundreds or thousands of servers and need the codebase to scale accordingly. For the single-server or small-community use case this guide covers, raw discord.js with a well-organized file structure gives you everything you need without the abstraction overhead.

If you're evaluating whether Discord or Slack makes more sense for your community or internal tooling use case, the Slack API vs Discord API comparison covers the platform differences in depth.

1. Setup

Create Discord Application

  1. Go to Discord Developer Portal
  2. Click "New Application" → Name it
  3. Go to "Bot" → Click "Add Bot"
  4. Copy the Bot Token
  5. Enable: Message Content Intent, Server Members Intent
  6. Go to OAuth2 → URL Generator → Select: bot, applications.commands → Select permissions → Copy invite URL → Open in browser to add to your server

Install Dependencies

npm init -y
npm install discord.js
npm install -D typescript @types/node tsx
npx tsc --init

Project Structure

src/
  index.ts          # Bot startup
  commands/         # Slash command handlers
    ping.ts
    info.ts
    poll.ts
  events/           # Event handlers
    ready.ts
    interactionCreate.ts
  deploy-commands.ts # Register commands with Discord

Initialize Bot

// src/index.ts
import { Client, GatewayIntentBits, Collection } from 'discord.js';
import { readdir } from 'fs/promises';
import { join } from 'path';

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

// Load commands
client.commands = new Collection();

async function loadCommands() {
  const commandFiles = await readdir(join(__dirname, 'commands'));
  for (const file of commandFiles) {
    if (!file.endsWith('.ts') && !file.endsWith('.js')) continue;
    const command = await import(join(__dirname, 'commands', file));
    client.commands.set(command.data.name, command);
  }
}

// Handle interactions
client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  const command = client.commands.get(interaction.commandName);
  if (!command) return;

  try {
    await command.execute(interaction);
  } catch (error) {
    console.error(error);
    const reply = { content: 'There was an error executing this command!', ephemeral: true };
    if (interaction.replied) {
      await interaction.followUp(reply);
    } else {
      await interaction.reply(reply);
    }
  }
});

client.once('ready', (c) => {
  console.log(`✅ Logged in as ${c.user.tag}`);
});

loadCommands().then(() => {
  client.login(process.env.DISCORD_TOKEN);
});

2. Slash Commands

Slash commands in Discord are not like command prefixes from older bot libraries. They are structured application commands registered through the Discord API, with defined parameter types, option descriptions, and optional validation rules. Discord renders them natively in the client — users see autocomplete suggestions as they type, with descriptions for each parameter pulled directly from your command definition. This is a significantly better UX than the old approach of parsing raw message text, but it introduces a registration requirement: your commands must be registered via the REST API before Discord will respond to them. Until a command is registered, typing /ping in your server produces no response — not even an error — because Discord's client never sends the interaction.

Commands can be registered at two scopes. Guild commands are scoped to a specific server and propagate instantly, making them the correct choice for development and testing. Global commands are visible across all servers where the bot is installed and are required for production, but take up to one hour to propagate after registration. The deploy script in section 3 uses guild deployment; swap the route for global deployment before releasing.

Ping Command

// src/commands/ping.ts
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';

export const data = new SlashCommandBuilder()
  .setName('ping')
  .setDescription('Check bot latency');

export async function execute(interaction: ChatInputCommandInteraction) {
  const sent = await interaction.reply({
    content: 'Pinging...',
    fetchReply: true,
  });

  const latency = sent.createdTimestamp - interaction.createdTimestamp;
  const apiLatency = Math.round(interaction.client.ws.ping);

  await interaction.editReply(
    `🏓 Pong!\nLatency: ${latency}ms\nAPI Latency: ${apiLatency}ms`
  );
}

Info Command with Embeds

// src/commands/info.ts
import { SlashCommandBuilder, EmbedBuilder, ChatInputCommandInteraction } from 'discord.js';

export const data = new SlashCommandBuilder()
  .setName('info')
  .setDescription('Get server or user info')
  .addSubcommand(sub =>
    sub.setName('server').setDescription('Get server info'))
  .addSubcommand(sub =>
    sub.setName('user')
      .setDescription('Get user info')
      .addUserOption(opt =>
        opt.setName('target').setDescription('The user').setRequired(false)));

export async function execute(interaction: ChatInputCommandInteraction) {
  const subcommand = interaction.options.getSubcommand();

  if (subcommand === 'server') {
    const guild = interaction.guild!;
    const embed = new EmbedBuilder()
      .setTitle(guild.name)
      .setThumbnail(guild.iconURL())
      .addFields(
        { name: 'Members', value: `${guild.memberCount}`, inline: true },
        { name: 'Created', value: `<t:${Math.floor(guild.createdTimestamp / 1000)}:R>`, inline: true },
        { name: 'Channels', value: `${guild.channels.cache.size}`, inline: true },
      )
      .setColor(0x5865F2)
      .setTimestamp();

    await interaction.reply({ embeds: [embed] });
  }

  if (subcommand === 'user') {
    const user = interaction.options.getUser('target') || interaction.user;
    const embed = new EmbedBuilder()
      .setTitle(user.displayName)
      .setThumbnail(user.displayAvatarURL({ size: 256 }))
      .addFields(
        { name: 'ID', value: user.id, inline: true },
        { name: 'Joined', value: `<t:${Math.floor(user.createdTimestamp / 1000)}:R>`, inline: true },
      )
      .setColor(0x5865F2);

    await interaction.reply({ embeds: [embed] });
  }
}

Poll Command with Buttons

// src/commands/poll.ts
import {
  SlashCommandBuilder, EmbedBuilder, ActionRowBuilder,
  ButtonBuilder, ButtonStyle, ChatInputCommandInteraction,
} from 'discord.js';

export const data = new SlashCommandBuilder()
  .setName('poll')
  .setDescription('Create a poll')
  .addStringOption(opt =>
    opt.setName('question').setDescription('Poll question').setRequired(true))
  .addStringOption(opt =>
    opt.setName('option1').setDescription('First option').setRequired(true))
  .addStringOption(opt =>
    opt.setName('option2').setDescription('Second option').setRequired(true));

const pollVotes = new Map<string, Map<string, Set<string>>>();

export async function execute(interaction: ChatInputCommandInteraction) {
  const question = interaction.options.getString('question')!;
  const option1 = interaction.options.getString('option1')!;
  const option2 = interaction.options.getString('option2')!;

  const pollId = `poll_${Date.now()}`;
  pollVotes.set(pollId, new Map([
    ['option1', new Set()],
    ['option2', new Set()],
  ]));

  const embed = new EmbedBuilder()
    .setTitle(`📊 ${question}`)
    .addFields(
      { name: option1, value: 'Votes: 0', inline: true },
      { name: option2, value: 'Votes: 0', inline: true },
    )
    .setColor(0x5865F2)
    .setFooter({ text: `Poll ID: ${pollId}` });

  const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
    new ButtonBuilder()
      .setCustomId(`${pollId}_option1`)
      .setLabel(option1)
      .setStyle(ButtonStyle.Primary),
    new ButtonBuilder()
      .setCustomId(`${pollId}_option2`)
      .setLabel(option2)
      .setStyle(ButtonStyle.Secondary),
  );

  await interaction.reply({ embeds: [embed], components: [row] });
}

3. Deploy Commands

During active development, always deploy to a specific guild rather than globally. Guild deployment is instant — run the script, and the command is immediately available in your test server. Global deployment can take up to one hour to propagate across Discord's infrastructure, which makes the iteration loop painful when you're still refining command options or fixing parameter names. The workflow is: develop and test with guild commands, then switch the Routes.applicationGuildCommands call to Routes.applicationCommands (omitting the guild ID) for the production release.

// src/deploy-commands.ts
import { REST, Routes } from 'discord.js';
import { readdir } from 'fs/promises';
import { join } from 'path';

const commands = [];
const commandFiles = await readdir(join(__dirname, 'commands'));

for (const file of commandFiles) {
  if (!file.endsWith('.ts') && !file.endsWith('.js')) continue;
  const command = await import(join(__dirname, 'commands', file));
  commands.push(command.data.toJSON());
}

const rest = new REST().setToken(process.env.DISCORD_TOKEN!);

// Deploy to a specific guild (instant, for development)
await rest.put(
  Routes.applicationGuildCommands(
    process.env.DISCORD_CLIENT_ID!,
    process.env.DISCORD_GUILD_ID!
  ),
  { body: commands }
);

console.log(`Deployed ${commands.length} commands`);

Run: npx tsx src/deploy-commands.ts

4. Button Interactions

Discord's component interaction system gives buttons, select menus, and modals a first-class place in the API rather than treating them as message attachments. When a user clicks a button, Discord sends an interaction event to your bot — the same event type used for slash commands, handled through the same interactionCreate listener. Each button carries a customId string that you define when building the component, which is how you route button clicks to the correct handler logic.

Ephemeral responses are a critical tool in interactive component design. Marking a reply as ephemeral: true causes it to appear only to the user who triggered the interaction, invisible to everyone else in the channel. For poll votes, confirmation messages, error feedback, and any sensitive output, ephemeral replies keep the channel clean and prevent one user's interactions from creating noise for everyone else. They are also the correct pattern for command feedback that is only relevant to the invoking user — "you've already voted," "you don't have permission for this action," and similar messages should almost always be ephemeral.

// In your interactionCreate handler
client.on('interactionCreate', async (interaction) => {
  if (interaction.isButton()) {
    const [pollId, option] = interaction.customId.split('_');

    // Handle poll vote
    if (pollId.startsWith('poll')) {
      const votes = pollVotes.get(`${pollId}_${option.split('_')[0]}`);
      // Toggle vote, update embed...
      await interaction.reply({
        content: `You voted for option ${option}!`,
        ephemeral: true,
      });
    }
  }
});

5. Message Moderation

The messageCreate event — which fires for every message posted in channels your bot can read — requires the MessageContent privileged intent. Privileged intents are not enabled by default. They must be explicitly toggled on in the Discord Developer Portal under your bot's settings, and for bots in 100 or more servers, they require Discord's manual review and approval before they can be used. This is a gate that catches many developers by surprise when moving from a small test server to wider deployment. The GuildMembers intent, needed for member-related events, carries the same requirement. Plan for the review process if you expect your bot to grow past the 100-server threshold.

// Auto-moderate messages
client.on('messageCreate', async (message) => {
  if (message.author.bot) return;

  // Link filter
  const linkRegex = /https?:\/\/[^\s]+/gi;
  if (linkRegex.test(message.content) && !message.member?.permissions.has('ManageMessages')) {
    await message.delete();
    await message.channel.send({
      content: `${message.author}, links are not allowed in this channel.`,
    });
    return;
  }

  // Spam detection (simple)
  const recentMessages = await message.channel.messages.fetch({ limit: 5 });
  const userMessages = recentMessages.filter(
    m => m.author.id === message.author.id &&
    Date.now() - m.createdTimestamp < 5000
  );

  if (userMessages.size >= 5) {
    await message.member?.timeout(60_000, 'Spam detected');
    await message.channel.send(`${message.author} has been timed out for spam.`);
  }
});

6. Run the Bot

# Development
npx tsx --watch src/index.ts

# Production
npx tsc && node dist/index.js

Production Deployment

Before shipping your bot to a real server, reliability becomes the dominant concern. discord.js handles WebSocket reconnection automatically, but there are several things it cannot handle for you. Unhandled promise rejections in command handlers will silently swallow errors unless you have global rejection handlers in place — add process.on('unhandledRejection', ...) to your entry point to surface these. For persistent data like poll votes, moderation logs, and user preferences, in-memory storage is a liability: any process restart wipes the state. Use a lightweight database like SQLite for single-server bots or a hosted Postgres instance for anything more complex. For process management on a VPS, PM2 provides automatic restarts on crash and a clean logging interface; on Railway or Fly.io the platform handles process management for you. For guidance on building systems that recover gracefully from network errors, rate limits, and partial failures, see our guide to resilient API integrations.

PlatformSetupCost
RailwayConnect GitHub, deployFree tier available
Fly.ioDockerfile, fly deployFree tier (3 VMs)
VPS (DigitalOcean)PM2 + Node.js$4/month
Raspberry Pisystemd serviceHardware cost only

Scaling Beyond a Single Server

The architecture in this guide works well for a bot running on one or a handful of servers, but breaks down when you approach the 2,500-guild threshold that triggers Discord's requirement for sharding. Sharding splits your bot's connection to Discord across multiple processes, each maintaining a WebSocket connection for a subset of guilds. discord.js has built-in sharding support via ShardingManager, but it changes how you share state between shards — in-memory Maps like pollVotes in the poll command will not be visible across shard boundaries. Any cross-shard state needs to move to a shared database layer before you implement sharding.

Rate limiting is the other scaling concern. Discord's API imposes rate limits per route, per bot, and per guild. Most command responses complete well within these limits, but bulk operations — sending messages to many channels, fetching member lists from large servers, or running automated tasks in a loop — can trigger 429 responses. discord.js handles most rate limiting automatically by queuing requests and respecting the Retry-After header, but you should still structure your bot to avoid unnecessary API calls: cache guild member objects rather than re-fetching them on every message, and avoid fetching message history unless the command explicitly requires it.

If you intend to distribute your bot for others to add to their servers, you need an OAuth2 installation flow. The Developer Portal's OAuth2 URL Generator is sufficient for small-scale distribution, but for a published bot you'll typically want a landing page that calls Discord's authorization endpoint and handles the OAuth callback to record which guilds have installed the bot. This is also where you'd implement premium feature gating if you plan to monetize.

Common Mistakes

MistakeImpactFix
Not enabling intentsBot can't read messagesEnable intents in Developer Portal
Global command deploymentTakes up to 1 hour to updateUse guild commands for development
Not handling interaction timeouts"This interaction failed" after 3 secondsRespond or defer within 3 seconds
Blocking the event loopBot becomes unresponsiveUse async/await, avoid sync operations
Storing state in memoryLost on restartUse a database for persistent data

Choosing a bot platform? See our Slack API vs Discord API comparison and the guide to building a Slack bot — platform differences, use cases, and developer experience.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.