Skip to content
Current coupon:10% offUse it in your order

Your first discord.js bot: a slash command step by step

Write a discord.js 14 bot with the /ping and /pozdrav slash commands, register the commands over REST, learn to reply within 3 seconds and run the bot at StormHost.

  • advanced
  • ~30 min
  • Updated 2 Oct 2026

We'll write a bot in the discord.js library with two slash commands: /ping and /pozdrav (“greet”) with a parameter. We'll explain every line and finally run the bot at StormHost.

What you'll need

  • Node.js LTS on your computer (download it from nodejs.org (opens in a new window)) and a code editor, for example Visual Studio Code
  • An application in the Discord Developer Portal, a token and the bot invited to your test server with the applications.commands scope (guide)
  • A Discord bot at StormHost to keep it running
  • About 30 minutes

How it works#

A bot with slash commands has three parts:

  1. Command definitions – the command's name, description and parameters. You tell Discord about them once, with a separate script over the REST API (registration).
  2. The running bot – connects to Discord (the gateway) and waits for events.
  3. Interaction handling – when someone uses a command, Discord sends the bot an interaction and the bot has to reply within 3 seconds. Otherwise the user sees that the application didn't respond.

Registration and running the bot are separate: you only need to register the commands again when you change their definition (name, description, parameters). Changes to the reply code don't need a new registration.

Setting up the project#

  1. Create a muj-bot folder and open a terminal in it (in VS Code, Ctrl+`).

  2. Create the project and install the libraries:

    bash
    npm init -y
    npm install discord.js dotenv
    • npm init -y – creates package.json with default values (-y = answers “yes” to every question).
    • npm install discord.js dotenv – installs discord.js and dotenv (loads the .env file) and adds them to dependencies.
  3. In package.json, set "main": "index.js" and "type": "commonjs". The examples use require(). The fields are explained in the uploading and starting the bot guide.

  4. Create a .env file:

    .envini
    DISCORD_TOKEN=<BOT_TOKEN>
    CLIENT_ID=<APPLICATION_ID>
    GUILD_ID=<SERVER_ID>

    Replace <BOT_TOKEN> with your bot token, <APPLICATION_ID> with the Application ID from the General Information page in the Developer Portal and <SERVER_ID> with the ID of your test Discord server. Where to find them is covered in the token and intents guide.

  5. Create a .gitignore file with two lines: node_modules and .env.

The resulting structure:

text
muj-bot/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
├── index.js
├── deploy-commands.js
└── commands/
    └── utility/
        ├── ping.js
        └── pozdrav.js

Commands#

Each command has its own file with two parts: data (the definition for Discord) and execute (what happens when someone uses it).

commands/utility/ping.jsJavaScript
const { SlashCommandBuilder, MessageFlags } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Replies with Pong! and shows the bot latency.'),

  async execute(interaction) {
    await interaction.reply({
      content: `Pong! Latency: ${interaction.client.ws.ping} ms`,
      flags: MessageFlags.Ephemeral,
    });
  },
};
LineWhat it does
require('discord.js')Loads the SlashCommandBuilder class (builds the command definition) and MessageFlags (message flags) from the library.
.setName('ping')The command name the user types after the slash. 1–⁠32 characters, only lowercase letters, digits, - and _, no spaces.
.setDescription('…')The description in the command menu. Required, 1–⁠100 characters.
async execute(interaction)The function the bot calls when the command is used. interaction carries everything about it (who, where, parameters).
interaction.reply({ … })The reply to the command. It has to arrive within 3 seconds.
interaction.client.ws.pingThe latency of the bot's connection to Discord in milliseconds (“Latency” in the reply). Right after startup it can be -1 until it's measured.
flags: MessageFlags.EphemeralOnly the person who used the command sees the reply. Others in the channel don't.

A command with a parameter:

commands/utility/pozdrav.jsJavaScript
const { SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('pozdrav')
    .setDescription('Greets the given person.')
    .addStringOption((option) =>
      option.setName('jmeno').setDescription('Who to greet').setRequired(true),
    ),

  async execute(interaction) {
    const jmeno = interaction.options.getString('jmeno');
    await interaction.reply(`Hi, ${jmeno}! ${interaction.user.username} says hello.`);
  },
};
  • .addStringOption(…) – adds a text parameter (jmeno = name). Discord offers it to the user as a jmeno: field.
  • .setRequired(true) – the parameter is required; the command can't be sent without it.
  • interaction.options.getString('jmeno') – reads the parameter value.
  • interaction.user.username – the username of the person who used the command.
  • interaction.reply('…') – a public reply in the channel.

Other parameter types: addIntegerOption (whole number), addBooleanOption (yes/no), addUserOption (server member), addChannelOption (channel), addRoleOption (role).

Registering commands#

The deploy-commands.js script loads the definitions of all commands and sends them to Discord. You run it by hand, only when you add a command or change its definition.

deploy-commands.jsJavaScript
const fs = require('node:fs');
const path = require('node:path');
require('dotenv').config({ path: path.join(__dirname, '.env'), quiet: true });
const { REST, Routes } = require('discord.js');

const commands = [];
const foldersPath = path.join(__dirname, 'commands');
for (const folder of fs.readdirSync(foldersPath)) {
  const commandsPath = path.join(foldersPath, folder);
  for (const file of fs.readdirSync(commandsPath).filter((f) => f.endsWith('.js'))) {
    const command = require(path.join(commandsPath, file));
    if ('data' in command && 'execute' in command) {
      commands.push(command.data.toJSON());
    } else {
      console.log(`[WARNING] The file ${file} is missing "data" or "execute".`);
    }
  }
}

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

(async () => {
  try {
    console.log(`Registering commands: ${commands.length}`);
    const data = await rest.put(
      Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
      { body: commands },
    );
    console.log(`Done, commands registered: ${data.length}`);
  } catch (error) {
    console.error(error);
  }
})();
PartWhat it does
fs.readdirSync(foldersPath)Goes through the subfolders of commands (for example utility) and the .js files in them.
command.data.toJSON()Converts the command definition to the format the Discord API expects.
new REST().setToken(…)A Discord REST API client authenticated with the bot token. It doesn't connect as a bot, it only sends the request.
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID)The route for the commands of a single server (guild).
rest.put(…, { body: commands })Replaces all commands on the server with this list. A command missing from the list disappears from Discord.

Run it on your computer in the project folder:

bash
node deploy-commands.js
Outputtext
Registering commands: 2
Done, commands registered: 2

Server or global commands?#

TypeRouteWhere they workWhen to use
Server (guild)Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID)Only on the GUILD_ID server.Development and testing, a bot for one server.
GlobalRoutes.applicationCommands(CLIENT_ID)On every server where the bot is invited with the applications.commands scope, and by default in direct messages too.A finished bot for multiple servers.

If you have the same command registered both for the server and globally, you'll see it twice on your test server. Before switching to global registration, delete the server commands: run the server registration with an empty list (body: []).

Discord limits command creation to 200 per day per server. So don't run the registration script every time the bot starts.

The main bot file#

index.jsJavaScript
const fs = require('node:fs');
const path = require('node:path');
require('dotenv').config({ path: path.join(__dirname, '.env'), quiet: true });
const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require('discord.js');

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

// Load commands from the commands folder
client.commands = new Collection();
const foldersPath = path.join(__dirname, 'commands');
for (const folder of fs.readdirSync(foldersPath)) {
  const commandsPath = path.join(foldersPath, folder);
  for (const file of fs.readdirSync(commandsPath).filter((f) => f.endsWith('.js'))) {
    const command = require(path.join(commandsPath, file));
    if ('data' in command && 'execute' in command) {
      client.commands.set(command.data.name, command);
    }
  }
}

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

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

  const command = interaction.client.commands.get(interaction.commandName);
  if (!command) {
    console.error(`Command ${interaction.commandName} doesn't exist.`);
    return;
  }

  try {
    await command.execute(interaction);
  } catch (error) {
    console.error(error);
    const odpoved = { content: 'There was an error while running this command.', flags: MessageFlags.Ephemeral };
    if (interaction.replied || interaction.deferred) {
      await interaction.followUp(odpoved);
    } else {
      await interaction.reply(odpoved);
    }
  }
});

client.login(process.env.DISCORD_TOKEN);
PartWhat it does
new Client({ intents: [GatewayIntentBits.Guilds] })Creates the bot. The Guilds intent is enough for slash commands and isn't privileged. Reading regular messages would need GuildMessages and MessageContent.
client.commands = new Collection()A list of commands keyed by name. Collection is an extended Map from discord.js.
client.commands.set(command.data.name, command)Stores the command under its name, for example ping.
client.once(Events.ClientReady, …)Runs once after a successful login. readyClient.user.tag is the bot's name.
client.on(Events.InteractionCreate, …)Runs on every interaction (command, button, form).
interaction.isChatInputCommand()Lets through only slash commands and ignores other interactions.
client.commands.get(interaction.commandName)Finds the command file by the name the user used.
try { … } catch (error) { … }An error in a command must not crash the bot. The user gets an error reply and the error is printed to the console.
interaction.replied / interaction.deferredWhen the bot has already replied or deferred the reply, it has to send any further message with followUp.
client.login(process.env.DISCORD_TOKEN)Logs the bot in with the token from .env.

Try the bot on your computer:

bash
node index.js
Outputtext
Ready! Logged in as MujBot#1234

On Discord, type /ping and pick your bot's command.

Replying to a slow command#

When a command needs more than 3 seconds (a database query, a call to another API), defer the reply first and send the result later:

JavaScript
async execute(interaction) {
  await interaction.deferReply();
  const vysledek = await nejakaDlouhaPrace();
  await interaction.editReply(`Done: ${vysledek}`);
}
  • interaction.deferReply() – acknowledges the interaction right away. The user sees that the bot is thinking. For a reply only they can see, use deferReply({ flags: MessageFlags.Ephemeral }).
  • interaction.editReply(…) – replaces “thinking” with the actual reply. You have to send a deferred reply within 15 minutes.
  • nejakaDlouhaPrace() – your own function; the name is just an example.

Replying with an embed#

An embed is a message with a colored stripe, a title and fields. It's good for presenting information clearly. The /server command shows details about the server:

commands/utility/server.jsJavaScript
const { SlashCommandBuilder, EmbedBuilder, InteractionContextType } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('server')
    .setDescription('Information about this Discord server.')
    .setContexts(InteractionContextType.Guild),

  async execute(interaction) {
    const guild = interaction.guild;
    const embed = new EmbedBuilder()
      .setColor(0xe11d48)
      .setTitle(guild.name)
      .addFields(
        { name: 'Members', value: String(guild.memberCount), inline: true },
        { name: 'Created', value: guild.createdAt.toLocaleDateString('en-US'), inline: true },
      )
      .setTimestamp();
    await interaction.reply({ embeds: [embed] });
  },
};
PartWhat it does
.setContexts(InteractionContextType.Guild)The command can only be used on a server, not in direct messages (where interaction.guild wouldn't exist).
new EmbedBuilder()Starts building an embed.
.setColor(0xe11d48)The stripe color as a hexadecimal number (red here).
.setTitle(guild.name)The title, here the server name.
.addFields({ name, value, inline })Fields with a label and a value. value must be text, hence String(…). inline: true puts the fields side by side.
guild.memberCountThe server's member count. Works with the Guilds intent, no privileged intent needed.
.setTimestamp()Adds the current time to the footer.
interaction.reply({ embeds: [embed] })Sends the embed. You can put several embeds in one message as an array.

After adding the file, run node deploy-commands.js again; the new command has to be registered.

Who can use a command#

You set the default permissions right in the command definition:

commands/moderace/smazat.jsJavaScript
const { SlashCommandBuilder, PermissionFlagsBits, InteractionContextType, MessageFlags } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('smazat')
    .setDescription('Deletes the latest messages in the channel.')
    .addIntegerOption((o) =>
      o.setName('pocet').setDescription('How many messages (1–100)').setMinValue(1).setMaxValue(100).setRequired(true),
    )
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
    .setContexts(InteractionContextType.Guild),

  async execute(interaction) {
    const pocet = interaction.options.getInteger('pocet');
    const smazane = await interaction.channel.bulkDelete(pocet, true);
    await interaction.reply({ content: `Messages deleted: ${smazane.size}`, flags: MessageFlags.Ephemeral });
  },
};
  • .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) – by default, only members with the Manage Messages permission see the command. A server admin can change this in the server settings (Integrations).
  • .setMinValue(1) and .setMaxValue(100) – Discord won't accept a number outside the range.
  • interaction.channel.bulkDelete(pocet, true) – deletes messages in bulk. true skips messages older than 14 days, which can't be bulk deleted.
  • The bot itself needs the Manage Messages permission in the channel, otherwise it fails with DiscordAPIError[50013]: Missing Permissions.

No command setting stops members with the Administrator permission. So register commands meant only for you (for example eval) only on a private test server.

Event: welcoming a new member#

Besides commands, a bot reacts to events. Welcoming a new member needs the privileged Server Members Intent: turn it on in the Developer Portal on the Bot page and add it to your code.

events/guildMemberAdd.jsJavaScript
const { Events } = require('discord.js');

module.exports = {
  name: Events.GuildMemberAdd,
  once: false,
  async execute(member) {
    const kanal = member.guild.systemChannel;
    if (!kanal) return;
    await kanal.send(`Welcome to the server, ${member}!`);
  },
};

In index.js, update the intents and add event loading (after the command loading):

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

// … command loading …

const eventsPath = path.join(__dirname, 'events');
for (const file of fs.readdirSync(eventsPath).filter((f) => f.endsWith('.js'))) {
  const event = require(path.join(eventsPath, file));
  if (event.once) {
    client.once(event.name, (...args) => event.execute(...args));
  } else {
    client.on(event.name, (...args) => event.execute(...args));
  }
}
  • GatewayIntentBits.GuildMembers – Discord then sends events when members join and leave. If the intent isn't enabled in the Developer Portal, the bot reports Used disallowed intents.
  • member.guild.systemChannel – the server's system channel (where Discord posts welcome messages). If the server doesn't have one, the bot sends nothing.
  • ${member} – inserts a mention of the new member (@name).
  • client.once / client.on – an event with once: true is handled only once (bot startup), the others every time.

Structuring a larger bot#

  • Commands grouped by category in folders such as commands/moderace, commands/zabava … The loading code above goes through all of them.
  • Events in the events folder, each in its own file with name, once and execute (see above). You can move the ClientReady and InteractionCreate handlers to events too.
  • Configuration (channel and role IDs) in config.json, secrets in .env.
  • Data (points, warnings) in a database, not in variables in memory, which are lost on restart.

Running it at StormHost#

  1. Run the command registration (node deploy-commands.js) on your computer. You don't need to run it on the server.
  2. Stop the bot on your computer (Ctrl+C). Two running copies with the same token steal each other's interactions.
  3. Upload index.js, package.json, package-lock.json and the commands folder with all its subfolders in the Files tab. If you added the new member welcome, upload the events folder too. Without it, the bot crashes at startup with ENOENT: no such file or directory. Create .env directly on the server. Steps: uploading and starting the bot.
  4. In the Settings tab, check that the main file is index.js.
  5. In the Console tab, click Start.

Verification#

  • The Console shows Ready! Logged in as … and the bot is online on Discord.
  • When you type / on your test server, the menu shows ping and pozdrav with your bot's icon.
  • /ping replies with a message only you can see, /pozdrav jmeno:Jan with a public message.

Troubleshooting#

Commands don't show up in the menu#

  • The registration didn't run: run node deploy-commands.js and check for the Done, commands registered: … output.
  • GUILD_ID is the ID of a different server than the one you're testing on.
  • The bot was invited without the applications.commands scope. Invite it again with a link that has both bot and applications.commands.
  • Discord caches the menu. Reload the Discord app (Ctrl+R).

Registration fails with DiscordAPIError[50001]: Missing Access#

The bot isn't on the GUILD_ID server, or doesn't have the applications.commands scope there. Check the server ID and invite the bot again.

Discord says “The application did not respond”#

The bot didn't reply within 3 seconds. Either it isn't running (check the Console), the command takes long and deferReply() is missing, or an error happened in the command before the reply (you'll find it in the Console).

DiscordAPIError[10062]: Unknown interaction#

The reply came too late (after 3 seconds), or two copies of the bot are running with the same token and the other one already handled the interaction. Use deferReply() and keep only one copy running.

DiscordAPIError[40060]: Interaction has already been acknowledged#

You called reply() twice on one interaction. Send the second message with followUp(), or use editReply() after deferReply().

[WARNING] The file … is missing "data" or "execute".#

A file in the commands folder doesn't export both parts. Check module.exports = { data, execute } and look for typos.

require is not defined in ES module scope or Cannot use import statement outside a module#

You're mixing CommonJS and ES modules. The examples are for CommonJS: package.json must have "type": "commonjs" (or no type field at all). Details in the logs and errors guide.

Summary

  • A slash command = a definition (SlashCommandBuilder), registration (deploy-commands.js over REST) and handling (Events.InteractionCreate).
  • Run the registration only when a definition changes. During development, register per server (applicationGuildCommands); register a finished bot globally (applicationCommands).
  • Reply to an interaction within 3 seconds. Start longer work with deferReply() and finish it with editReply().
  • Catch errors in commands with try/catch so one error doesn't crash the bot. Never run two copies with the same token.

Search the guides