Guide

Slash commands in discord.js

A discord.js slash command has two halves that live apart: registering it with Discord, and answering it in your bot. Most "my command does not show up" problems are the first half never running.

9 minute read

This is for discord.js 14. Check with npm ls discord.js; if it says 13 or lower, the code below will not work and upgrading is the first job.

The two halves

  1. Register: send Discord the list of commands, with their names, descriptions and options. Discord stores it. You only need to do this when the list changes.
  2. Handle: when someone uses a command, your running bot receives an interaction and replies to it.

A bot can handle commands it never registered (they just never appear), and Discord can show commands for a bot that is offline (they fail with The application did not respond). Keeping the two halves separate in your head makes both problems obvious.

A minimal bot

// index.js
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 === "ping") {
    await interaction.reply(`Pong! ${client.ws.ping} ms`);
  }
});

client.login(process.env.BOT_TOKEN);

Slash commands only need the Guilds intent. Message Content is for reading ordinary messages, which slash commands do not do.

Registering

Keep registration in its own script and run it when you add or change a command, not every time the bot starts:

// deploy-commands.js
const { REST, Routes, SlashCommandBuilder } = require("discord.js");

const commands = [
  new SlashCommandBuilder().setName("ping").setDescription("Check the bot is alive"),
].map((c) => c.toJSON());

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

(async () => {
  const data = await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands });
  console.log(`Registered ${data.length} commands`);
})();

CLIENT_ID is the Application ID from the General Information page in the Developer Portal. Run it with node --env-file=.env deploy-commands.js.

rest.put replaces the whole list in one request, so a command you delete from the array disappears from Discord too. Registering in a loop, one command at a time on every start, is how bots run into Discord's limit of 200 command creations a day.

Global or one server

While you are developing, register to your test server instead. It is instant and does not touch the commands everyone else sees:

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

Guild commands only exist in that server. When you switch to global, clear the test server's copy (rest.put(Routes.applicationGuildCommands(...), { body: [] })) or every command shows twice there.

Options

new SlashCommandBuilder()
  .setName("roll")
  .setDescription("Roll some dice")
  .addIntegerOption((o) =>
    o.setName("sides").setDescription("How many sides").setMinValue(2).setMaxValue(100))
  .addStringOption((o) =>
    o.setName("colour").setDescription("Pick one").addChoices(
      { name: "Red", value: "red" },
      { name: "Blue", value: "blue" },
    ))
  .addUserOption((o) => o.setName("for").setDescription("Who is it for"));

Reading them in the handler:

const sides = interaction.options.getInteger("sides") ?? 6;
const colour = interaction.options.getString("colour");   // null if not given
const target = interaction.options.getUser("for") ?? interaction.user;

Options are optional unless you call .setRequired(true), and required options have to be added before optional ones.

The three second rule

Discord gives your bot three seconds to answer. After that the user sees The application did not respond, and a late reply() throws Unknown interaction. For anything slow, acknowledge first:

await interaction.deferReply();             // "Bot is thinking..."
const result = await slowLookup(query);
await interaction.editReply(result);

After deferring you have 15 minutes. Use followUp() for extra messages. Replying twice throws Interaction has already been acknowledged; check interaction.replied || interaction.deferred if a code path might.

A reply only the user can see:

const { MessageFlags } = require("discord.js");
await interaction.reply({ content: "Only you can see this", flags: MessageFlags.Ephemeral });

Older examples use ephemeral: true, which still works but is deprecated in recent 14.x releases.

One file per command

The layout most discord.js bots settle on: a commands folder, each file exporting its definition and its handler.

// commands/ping.js
const { SlashCommandBuilder } = require("discord.js");

module.exports = {
  data: new SlashCommandBuilder().setName("ping").setDescription("Check the bot is alive"),
  async execute(interaction) {
    await interaction.reply("Pong");
  },
};
// in index.js
const fs = require("node:fs");
const path = require("node:path");
const { Collection } = require("discord.js");

client.commands = new Collection();
for (const file of fs.readdirSync(path.join(__dirname, "commands")).filter((f) => f.endsWith(".js"))) {
  const command = require(path.join(__dirname, "commands", file));
  client.commands.set(command.data.name, command);
}

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;
  const command = client.commands.get(interaction.commandName);
  if (!command) return;
  try {
    await command.execute(interaction);
  } catch (err) {
    console.error(err);
    const msg = { content: "Something went wrong.", flags: MessageFlags.Ephemeral };
    if (interaction.replied || interaction.deferred) await interaction.followUp(msg);
    else await interaction.reply(msg);
  }
});

The deploy script reads the same folder and sends every command.data.toJSON(), so a new file is a new command after one deploy.

Commands not showing up

  • The deploy script never ran, or ran before you added the command. Its output says how many were registered.
  • Wrong CLIENT_ID. It is the Application ID, not the bot's token, not a server ID.
  • Registered to a guild, looking in another server.
  • Invited without applications.commands. Re-invite with the bot and applications.commands scopes.
  • The Discord client has an old list. Ctrl+R (Cmd+R on a Mac).
  • DiscordAPIError[50001]: Missing Access when registering to a guild: the bot is not in that server.
  • Permissions: a command with .setDefaultMemberPermissions(...) is hidden from members without them, including you if you are testing on a second account.

Commands appear but nothing happens? The bot is either not running or not getting the event: see bot online but not responding.