Make a Discord bot in JavaScript

From nothing to a bot with slash commands, replies, embeds and buttons in your server. The first part takes about twenty minutes. You need Node.js installed and a Discord server you can add bots to.

14 min readBeginnerUpdated Examples tested with discord.js 14.27

1. Create the application

  1. Go to the Discord Developer Portal and press New Application.
  2. On General Information, copy the Application ID. You need it to register commands.
  3. Open Bot, press Reset Token, and copy the token. It is the bot's password; keep it private.
  4. Leave the privileged intents off for now. Only turn on Message Content Intent if your bot will read ordinary messages.

2. Invite it to your server

  1. OAuth2, then URL Generator.
  2. Tick bot and applications.commands, and under bot permissions tick Send Messages.
  3. Open the URL at the bottom and add the bot to your server.

3. Set up the project

You need Node.js 20 or newer (node -v); the current LTS release is the safe choice.

Terminal
mkdir my-bot
cd my-bot
npm init -y
npm install discord.js dotenv

Create .env with the token and the Application ID:

.env
BOT_TOKEN=paste-your-token-here
CLIENT_ID=paste-your-application-id-here

If you use Git, put .env and node_modules/ in .gitignore before your first commit.

4. Define the commands

Create commands.js, the one place your commands are described:

JavaScript
const { SlashCommandBuilder } = require("discord.js");

module.exports = [
  new SlashCommandBuilder().setName("hello").setDescription("Say hello"),
  new SlashCommandBuilder()
    .setName("roll")
    .setDescription("Roll a die")
    .addIntegerOption((o) => o.setName("sides").setDescription("How many sides").setMinValue(2)),
];

5. Register them with Discord

Create deploy-commands.js:

JavaScript
require("dotenv").config();
const { REST, Routes } = require("discord.js");
const commands = require("./commands");

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

rest
  .put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands.map((c) => c.toJSON()) })
  .then((data) => console.log(`Registered ${data.length} commands`))
  .catch(console.error);
Terminal
node deploy-commands.js

Run this again whenever you add or change a command. Not on every start: Discord limits how many commands you can create a day.

6. Write the bot

Create index.js:

JavaScript
require("dotenv").config();
const { Client, Events, GatewayIntentBits } = require("discord.js");

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

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

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === "hello") {
    await interaction.reply(`Hello, ${interaction.user}!`);
  }

  if (interaction.commandName === "roll") {
    const sides = interaction.options.getInteger("sides") ?? 6;
    await interaction.reply(`You rolled ${1 + Math.floor(Math.random() * sides)}`);
  }
});

client.login(process.env.BOT_TOKEN);

Add a start script to package.json so it is one command to run, here and on any host:

JSON
"scripts": {
  "start": "node index.js"
}

dotenv reads .env when there is one and does nothing when there is not, so the same code works on a host that provides the token another way. (Node's own --env-file=.env flag stops with an error if the file is missing, which is a common surprise after deploying.)

7. Run it

Terminal
npm start

You should see Logged in as and the bot turns green. Type / in your server and use /hello. If the commands are not listed, reload Discord with Ctrl+R.

8. Make new commands appear instantly while you test

The deploy script registers commands globally, for every server the bot is in, and changes can take a while to reach everyone's Discord. While you are building, register them to your own server instead, where they update the moment the script runs. Add your server's ID to .env:

.env
GUILD_ID=paste-your-server-id-here

and change the route in deploy-commands.js:

JavaScript
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID)

To find the ID, turn on Developer Mode in Discord (User Settings, Advanced), then right-click your server and choose Copy Server ID. When the bot is ready for other servers, switch back to Routes.applicationCommands, and run the server route once with { body: [] } to clear the test copy. Otherwise every command shows up twice in your server.

9. Reply to ordinary messages

Slash commands need nothing special. Reading what people type in chat does: it needs the Message Content intent, switched on in two places. In the Developer Portal, under Bot, turn on Message Content Intent. In index.js, ask for it, along with the intent that delivers server messages at all:

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

client.on(Events.MessageCreate, async (message) => {
  if (message.author.bot) return; // never answer bots, including yourself
  if (message.content.toLowerCase() === "ping") {
    await message.reply("pong");
  }
});

Without GuildMessages, the event never fires for server messages. Without MessageContent, it fires but message.content is an empty string for most messages, so the bot looks as if it ignores everyone. Asking for Message Content in code but not switching it on in the portal stops the bot at login with Used disallowed intents.

10. Send an embed

Embeds are the boxed messages with a title, fields and a colour bar. Add the command to the array in commands.js, importing InteractionContextType at the top next to SlashCommandBuilder:

JavaScript
new SlashCommandBuilder()
  .setName("serverinfo")
  .setDescription("Show some facts about this server")
  .setContexts(InteractionContextType.Guild),

and answer it in index.js, inside the InteractionCreate handler:

JavaScript
const { Colors, EmbedBuilder, time, TimestampStyles } = require("discord.js"); // at the top

if (interaction.commandName === "serverinfo") {
  const guild = interaction.guild;
  const embed = new EmbedBuilder()
    .setTitle(guild.name)
    .setColor(Colors.Blurple)
    .addFields(
      { name: "Members", value: String(guild.memberCount), inline: true },
      { name: "Created", value: time(guild.createdAt, TimestampStyles.LongDate), inline: true },
    );
  const icon = guild.iconURL(); // null for a server without an icon
  if (icon) embed.setThumbnail(icon);
  await interaction.reply({ embeds: [embed] });
}

setContexts(InteractionContextType.Guild) hides the command in DMs, where there is no server to describe and interaction.guild would be null. The command list changed, so run node deploy-commands.js again.

11. Add a button

Buttons sit in an action row under a message. This one counts its clicks and edits its own label. Add new SlashCommandBuilder().setName("counter").setDescription("A button that counts its clicks") to commands.js, deploy, then:

JavaScript
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } = require("discord.js"); // at the top

if (interaction.commandName === "counter") {
  let count = 0;
  const button = new ButtonBuilder()
    .setCustomId("count")
    .setLabel("Clicked 0 times")
    .setStyle(ButtonStyle.Primary);
  const row = new ActionRowBuilder().addComponents(button);

  const reply = await interaction.reply({ content: "Press it:", components: [row] });
  const collector = reply.createMessageComponentCollector({
    componentType: ComponentType.Button,
    time: 180_000, // stops listening after 3 minutes
  });

  collector.on("collect", async (click) => {
    count += 1;
    button.setLabel(`Clicked ${count} times`);
    await click.update({ components: [row] });
  });

  collector.on("end", () => {
    button.setDisabled(true);
    interaction.editReply({ components: [row] }).catch(console.error);
  });
}

A collector lives in the bot's memory, so the button stops working when its time runs out or the bot restarts; clicking it then shows This interaction failed, which is why this one greys itself out at the end. Buttons that must keep working for days are answered in the InteractionCreate handler instead, by checking interaction.isButton() and interaction.customId.

12. Keep one error from stopping the bot

discord.js emits the client's error event for connection and internal errors, and Node stops the bot if nothing listens for it. An error thrown inside your own async handler (a reply that came too late, a message that was deleted) is different: it is an unhandled promise rejection, and Node stops the whole bot on those too. Two lines in index.js turn both into log lines instead:

JavaScript
client.on(Events.Error, console.error);
process.on("unhandledRejection", console.error);

That keeps the bot running, but the person who used the command still gets no answer. For that, wrap command code in try/catch and reply with an error message, as the slash commands guide does.

Slash commands or prefix commands?

Prefix commands are the older !roll 20 style. discord.js has no built-in system for them; you read each message and split it yourself:

JavaScript
const PREFIX = "!";

client.on(Events.MessageCreate, async (message) => {
  if (message.author.bot || !message.content.startsWith(PREFIX)) return;
  const [name, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/);

  if (name === "roll") {
    const sides = Math.max(2, parseInt(args[0], 10) || 6);
    await message.reply(`You rolled ${1 + Math.floor(Math.random() * sides)}`);
  }
});

They need the Message Content intent from step 9. A bot in fewer than 100 servers can switch it on itself; past 100, Discord grants it only after a review. Slash commands need neither, show up in Discord's command menu with their options, and are what new bots should use. Once a bot has more than a handful of commands, give each its own file: the slash commands guide shows the layout most discord.js bots use.

If it did not work

  • TokenInvalid: An invalid token was provided: the token is wrong, or BOT_TOKEN is not set. Check .env is in the folder you ran the command from and the line is spelt exactly BOT_TOKEN=.
  • Cannot find module 'dotenv': run npm install dotenv in the project folder.
  • Used disallowed intents: your code asks for an intent that is switched off in the Developer Portal.
  • Commands never appear: the deploy script did not run, CLIENT_ID is wrong, or the invite link was missing applications.commands.
  • The bot ignores ordinary messages: GuildMessages or MessageContent is missing from the intents, or Message Content is off in the portal. See step 9.
  • DiscordAPIError[10062]: Unknown interaction: the bot took more than three seconds to answer. For anything slow (an API, a database), call await interaction.deferReply() first and answer with editReply.
  • The bot stops on the first error: nothing is listening for the client's error event. See step 12.

Where to go next

SnowServers runs discord.js bots from $3 a month with a 7-day free trial. Push this folder to GitHub (without .env), paste the repository link and your token into the panel, and npm start runs on its own.

Run it here. Flurry keeps a bot like this online all day for $3 a month, and the first week is free.

Start the free week

Press / or Ctrl K on any page to search.