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
- 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.
- 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.
Autocomplete
For a long or changing list, suggest values as the user types instead of fixing them with addChoices (an option can have one or the other, not both):
new SlashCommandBuilder()
.setName("fruit")
.setDescription("Find a fruit")
.addStringOption((o) =>
o.setName("name").setDescription("Which fruit").setRequired(true).setAutocomplete(true));const FRUITS = ["apple", "apricot", "banana", "blackberry", "cherry"];
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isAutocomplete() && interaction.commandName === "fruit") {
const typed = interaction.options.getFocused().toLowerCase();
const matches = FRUITS.filter((f) => f.includes(typed)).slice(0, 25);
await interaction.respond(matches.map((f) => ({ name: f, value: f })));
return;
}
if (!interaction.isChatInputCommand()) return;
// ... the commands, as before
});Suggestions arrive as their own kind of interaction, so they have to be answered before the isChatInputCommand() check, which is false for them. Discord shows at most 25. And unlike choices, autocomplete is only a suggestion: the user can still send any text, so check the value when the command runs.
Who can use a command
A moderation command wants two separate things: hidden from members who should not see it, and refused when they use it anyway.
// commands/purge.js
const { InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } = require("discord.js");
module.exports = {
data: new SlashCommandBuilder()
.setName("purge")
.setDescription("Delete recent messages")
.addIntegerOption((o) =>
o.setName("count").setDescription("How many").setRequired(true).setMinValue(1).setMaxValue(100))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.setContexts(InteractionContextType.Guild),
async execute(interaction) {
if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) {
return interaction.reply({ content: "You need Manage Messages for that.", flags: MessageFlags.Ephemeral });
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const deleted = await interaction.channel.bulkDelete(interaction.options.getInteger("count"), true);
await interaction.editReply(`Deleted ${deleted.size} messages`);
},
};setDefaultMemberPermissionshides the command from members without that permission. Server admins can change it under Server Settings, Integrations, so it is a default, not a guarantee.- The check in
executeruns every time the command is used. This is the one to rely on. - For kick, ban and timeout commands, which also need a check on who is being acted on, see the moderation commands guide.
setContexts(InteractionContextType.Guild)keeps it out of DMs, where there are no permissions to check andmemberPermissionsisnull.- The
trueinbulkDeleteskips messages older than 14 days, which Discord refuses to bulk delete; without it, one old message fails the whole request. The bot itself also needs Manage Messages in that channel, or Discord answersMissing Permissions.
Cooldowns
discord.js has no built-in cooldowns, but a Map of when each person last used each command is enough. In the InteractionCreate handler, after the isChatInputCommand() check:
const { MessageFlags, TimestampStyles, time } = require("discord.js"); // at the top
const COOLDOWN_MS = 10_000;
const lastUsed = new Map(); // at the top of the file, outside the handler
const key = `${interaction.commandName}:${interaction.user.id}`;
const readyAt = (lastUsed.get(key) ?? 0) + COOLDOWN_MS;
if (Date.now() < readyAt) {
return interaction.reply({
content: `Slow down: you can use this again ${time(Math.ceil(readyAt / 1000), TimestampStyles.RelativeTime)}.`,
flags: MessageFlags.Ephemeral,
});
}
lastUsed.set(key, Date.now());
setTimeout(() => lastUsed.delete(key), COOLDOWN_MS);That is one use per 10 seconds per person, per command; key on interaction.guildId instead to make it per server. time() and TimestampStyles come from discord.js and make a timestamp Discord shows as "in 7 seconds", counting down. The setTimeout clears each entry when it expires, so the map does not grow forever. It lives in memory, so cooldowns reset when the bot restarts.
Subcommands: /config set, /config show
new SlashCommandBuilder()
.setName("config")
.setDescription("Change the bot's settings")
.addSubcommand((s) =>
s.setName("set").setDescription("Set a setting")
.addStringOption((o) => o.setName("key").setDescription("Which setting").setRequired(true))
.addStringOption((o) => o.setName("value").setDescription("The new value").setRequired(true)))
.addSubcommand((s) => s.setName("show").setDescription("Show the settings"));if (interaction.commandName === "config") {
const sub = interaction.options.getSubcommand(); // "set" or "show"
if (sub === "set") {
const key = interaction.options.getString("key");
const value = interaction.options.getString("value");
await interaction.reply({ content: `${key} = ${value}`, flags: MessageFlags.Ephemeral });
} else if (sub === "show") {
await interaction.reply({ content: "...", flags: MessageFlags.Ephemeral });
}
}Subcommands keep related commands together in the menu, and the whole group counts as one command against Discord's limit of 100 per bot. Once a command has subcommands, /config on its own can no longer be used; only /config set and /config show.
Right-click commands
Context menu commands appear when someone right-clicks a message or a member, under Apps. Put the builder in the same array the deploy script sends:
const { ApplicationCommandType, ContextMenuCommandBuilder } = require("discord.js");
new ContextMenuCommandBuilder().setName("Report message").setType(ApplicationCommandType.Message);if (interaction.isMessageContextMenuCommand() && interaction.commandName === "Report message") {
await interaction.reply({
content: `Reported ${interaction.targetMessage.url}`,
flags: MessageFlags.Ephemeral,
});
return;
}Like autocomplete, these are not chat input commands, so this goes before the isChatInputCommand() check. They take no options and have no description, and the name is what people see, spaces and capitals included. Use ApplicationCommandType.User and interaction.targetUser for a member menu.
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");
},
};index.js then loads every file in the folder and hands each command to its own handler. This is the whole file, in place of the one at the top:
// index.js
const fs = require("node:fs");
const path = require("node:path");
const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require("discord.js");
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
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.once(Events.ClientReady, (c) => console.log(`Logged in as ${c.user.tag}`));
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);
}
});
client.login(process.env.BOT_TOKEN);Change the deploy script so it reads the same folder instead of a fixed list, and a new file is a new command after one deploy:
// deploy-commands.js
const fs = require("node:fs");
const path = require("node:path");
const { REST, Routes } = require("discord.js");
const commands = fs
.readdirSync(path.join(__dirname, "commands"))
.filter((f) => f.endsWith(".js"))
.map((f) => require(path.join(__dirname, "commands", f)).data.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`);
})();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 thebotandapplications.commandsscopes. - The Discord client has an old list. Ctrl+R (Cmd+R on a Mac).
DiscordAPIError[50001]: Missing Accesswhen 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.
Did this page help?
Thanks for the feedback.