BotNetwork

Discord slash commands in discord.js v14: register, handle, defer

Slash commands are the main way users talk to a Discord bot. They need no Message Content intent, they validate input for you and they show up in the client's command picker.

Published 4 min read

What you need

  • Node.js 18 or newer (the minimum discord.js states at the time of writing; the current v14 line was 14.27 when this was written).
  • An application in the Discord Developer Portal with a bot user, its token and its application ID.
  • The bot invited with the bot and applications.commands scopes. Our permissions calculator builds the invite URL.
npm install discord.js

Keep the token in an environment variable, never in the code. Locally, node --env-file=.env loads it. See securing bot tokens.

Define the commands

A slash command is data: a name (lowercase, 1-32 characters), a description (1-100 characters) and optional options. SlashCommandBuilder produces that JSON.

// commands.js
import { SlashCommandBuilder } from "discord.js";

export const commands = [
  new SlashCommandBuilder()
    .setName("ping")
    .setDescription("Check that the bot is alive"),
  new SlashCommandBuilder()
    .setName("remind")
    .setDescription("Set a reminder")
    .addIntegerOption(o => o.setName("minutes").setDescription("In how many minutes").setRequired(true).setMinValue(1).setMaxValue(1440))
    .addStringOption(o => o.setName("text").setDescription("What to remind you about").setRequired(true).setMaxLength(200)),
];

Discord enforces types, required flags and min/max values before your code runs, which removes a whole class of parsing bugs. If you prefer a visual editor, the Discord command builder outputs the same JSON.

Register: guild first, global later

Commands are registered through the REST API, not on every start. Run a small script when the definitions change.

// deploy-commands.js
import { REST, Routes } from "discord.js";
import { commands } from "./commands.js";

const rest = new REST().setToken(process.env.DISCORD_TOKEN);
const body = commands.map(c => c.toJSON());
const appId = process.env.APP_ID;

if (process.env.DEV_GUILD_ID) {
  await rest.put(Routes.applicationGuildCommands(appId, process.env.DEV_GUILD_ID), { body });
} else {
  await rest.put(Routes.applicationCommands(appId), { body });
}
console.log(`Registered ${body.length} commands`);
  • Guild commands apply to one server and are the fastest way to iterate during development.
  • Global commands apply everywhere the app is installed. Use them for production.
  • PUT replaces the whole list, so a command you remove from the array is deleted. That is usually what you want.

Registering in both places shows each command twice in your test server. Clear the guild list (body: []) once you move to global.

Handle the interaction

// index.js
import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js";

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

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

client.on(Events.InteractionCreate, async interaction => {
  if (!interaction.isChatInputCommand()) return;
  try {
    if (interaction.commandName === "ping") {
      await interaction.reply({ content: "Pong", flags: MessageFlags.Ephemeral });
    } else if (interaction.commandName === "remind") {
      const minutes = interaction.options.getInteger("minutes", true);
      const text = interaction.options.getString("text", true);
      await interaction.reply(`Ok, I will remind you in ${minutes} min: ${text}`);
    }
  } catch (err) {
    console.error(err);
    const msg = { content: "Something went wrong.", flags: MessageFlags.Ephemeral };
    if (interaction.deferred || interaction.replied) await interaction.followUp(msg);
    else await interaction.reply(msg);
  }
});

client.login(process.env.DISCORD_TOKEN);

Only the Guilds intent is needed. Slash commands do not require the privileged Message Content intent; see Discord gateway intents explained.

The 3-second rule and deferring

You must send an initial response within 3 seconds, or the user sees "The application did not respond". The interaction token then stays valid for 15 minutes for edits and follow-ups. For anything slow (an API call, a database query, an AI model), defer first:

await interaction.deferReply();            // shows "Bot is thinking..."
const answer = await slowWork();
await interaction.editReply(answer);

Pass { flags: MessageFlags.Ephemeral } to deferReply if the final answer should be private. You cannot change visibility after deferring.

Autocomplete

For options with many possible values, set .setAutocomplete(true) on a string option and answer isAutocomplete() interactions with up to 25 choices:

if (interaction.isAutocomplete()) {
  const typed = interaction.options.getFocused().toLowerCase();
  const matches = CITIES.filter(c => c.toLowerCase().startsWith(typed)).slice(0, 25);
  return interaction.respond(matches.map(c => ({ name: c, value: c })));
}

Autocomplete responses also have to arrive within the 3-second window, so keep the lookup fast (in memory or cached).

Structure as the bot grows

Put each command in its own file exporting data and execute, load them into a Collection keyed by name, and look up interaction.commandName in it. The official guide shows this pattern. Restrict admin commands with .setDefaultMemberPermissions() and .setContexts() instead of checking roles by hand.

Frequently asked questions

Why don't my slash commands show up?

Usually one of: the commands were never registered (run the deploy script), they were registered to a different guild ID, the bot was invited without the applications.commands scope, or the client has a stale list (restart the Discord app). Our troubleshooting checklist covers the rest.

Do I need to register commands every time the bot starts?

No. Registration is persistent. Re-run it only when names, descriptions or options change. Registering on every start wastes rate-limited API calls.

Should I use reply or followUp?

reply (or deferReply) is the one required initial response. followUp sends extra messages afterwards, and editReply changes the first one.

Sources