Slash commands in depth

Grow one command set past /ping — typed options, subcommands, rich embeds, and buttons that carry data — with the discord.js v14 pieces you'll reuse in every bot.

The /ping bot from the first guide answered with one plain line. Real commands take input, reply with formatted embeds, and put buttons under their messages. This guide grows a single command set — a /greet command — from a bare reply into something that reads options, shows an embed, and answers a button click, using the discord.js v14 pieces you'll reach for again and again.

At a glance
You need comfort with the discord.js bot guide, a bot token from Your first Discord bot, and a Falix server running the Node.js application
Time about twenty-five minutes

We keep the discord.js guide's exact Client + Events + ClientReady + InteractionCreate structure and only enrich it.

Where a command is born, and where it's answered

Two places, same as before. You register commands in the ClientReady handler with application.commands.set([...]), and you answer them in InteractionCreate. Two things to remember as you go:

  • Restart after changing a command's shape. A command's name, description, and options are only sent to Discord when ClientReady runs, so a definition change needs a restart to take effect.
  • First-time global registration lags. A brand-new command can take a few minutes to appear the very first time — the same delay you saw with /ping.

Bring in the builders you'll use, alongside the imports from the first guide:

const {
  Client, Events, GatewayIntentBits,
  SlashCommandBuilder, EmbedBuilder,
  ActionRowBuilder, ButtonBuilder, ButtonStyle,
} = require('discord.js');

Typed options: give a command inputs

Instead of the plain { name, description } object from the first guide, describe /greet with a SlashCommandBuilder so it can take arguments:

const greet = new SlashCommandBuilder()
  .setName('greet')
  .setDescription('Greet a member')
  .addUserOption(option =>
    option.setName('who')
      .setDescription('Who to greet')
      .setRequired(true))
  .addBooleanOption(option =>
    option.setName('loud')
      .setDescription('Shout it'))
  .addStringOption(option =>
    option.setName('note')
      .setDescription('An optional message to add')
      .setMaxLength(100));

Register it in ClientReady, exactly where the plain objects went:

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set([greet]);
  console.log(`Listening as ${c.user.tag}`);
});

set([...]) replaces your whole command list, so keep every command in that array. Now read the options back in InteractionCreate — each getter matches the option's type:

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

  if (interaction.commandName === 'greet') {
    const who = interaction.options.getUser('who');
    const loud = interaction.options.getBoolean('loud') ?? false;
    const note = interaction.options.getString('note');

    let text = `Hello, ${who}!`;
    if (note) text += ` ${note}`;
    if (loud) text = text.toUpperCase();

    await interaction.reply(text);
  }
});

getUser returns a User, getBoolean a true/false, getString the text. Each getter pairs with the builder call that declared the option:

Option type Builder call Getter
User .addUserOption() getUser()
Boolean .addBooleanOption() getBoolean()
String .addStringOption() getString()

A required option is always there; an optional one returns null when it's left out, which is why loud falls back to false. And setMaxLength(100) caps note at 100 characters — Discord enforces that before your code ever runs.

Reply with an embed

A plain string works; an embed is what makes a bot look finished. Swap the reply for an EmbedBuilder:

const embed = new EmbedBuilder()
  .setColor(0x5865f2)
  .setTitle('👋 Greetings')
  .setDescription(text)
  .setThumbnail(who.displayAvatarURL())
  .addFields(
    { name: 'From', value: `${interaction.user}`, inline: true },
    { name: 'To', value: `${who}`, inline: true },
  );

await interaction.reply({ embeds: [embed] });

setColor takes a hex number, addFields lays out labelled values (inline: true puts them side by side), and setThumbnail shows the greeted member's avatar. The one change to how you reply: pass { embeds: [...] } instead of a bare string.

Add a button

Buttons live in an action row under the message. Build a row and send it with the embed:

const row = new ActionRowBuilder().addComponents(
  new ButtonBuilder()
    .setCustomId(`wave:${who.id}`)
    .setLabel('Wave back')
    .setStyle(ButtonStyle.Primary),
);

await interaction.reply({ embeds: [embed], components: [row] });

The trick is the customId: wave:${who.id}. A customId is just a string Discord hands back to you when the button is clicked, so you can pack data into it — here an action (wave) and the ID of the person to wave at, joined by a colon.

💡 Tip: A customId maxes out at 100 characters, so carry an ID, not a whole payload.

Button clicks arrive through the same InteractionCreate event, so check for them first and pull your data back out:

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isButton()) {
    const [action, userId] = interaction.customId.split(':');
    if (action === 'wave') {
      await interaction.reply({
        content: `<@${userId}>, someone waved back! 👋`,
        ephemeral: true,
      });
    }
    return;
  }

  if (!interaction.isChatInputCommand()) return;
  // ...your command handlers from above...
});

isButton() tells a button apart from a slash command, and splitting customId on : recovers the action and the data you stored. Routing on that first piece lets one handler serve many different buttons.

Ephemeral replies

Notice ephemeral: true above. An ephemeral reply is visible only to the person who triggered it and quietly disappears — perfect for confirmations, errors, and anything that would otherwise clutter the channel. Add it to any reply: await interaction.reply({ content: '...', ephemeral: true });.

Subcommands: group related actions

When one command has several modes — add, list, remove — use subcommands instead of a pile of top-level commands. Here's a /todo with add and list:

const todo = new SlashCommandBuilder()
  .setName('todo')
  .setDescription('Manage your to-do list')
  .addSubcommand(sub =>
    sub.setName('add')
      .setDescription('Add an item')
      .addStringOption(option =>
        option.setName('item')
          .setDescription('What to add')
          .setRequired(true)
          .setMaxLength(200)))
  .addSubcommand(sub =>
    sub.setName('list')
      .setDescription('Show your items'));

Add it to the registration array — await c.application.commands.set([greet, todo]); — and declare a store at the top of the file, next to your client:

const todos = new Map(); // userId -> string[]

Then branch on which subcommand ran with getSubcommand():

if (interaction.commandName === 'todo') {
  const sub = interaction.options.getSubcommand();
  const list = todos.get(interaction.user.id) ?? [];

  if (sub === 'add') {
    list.push(interaction.options.getString('item'));
    todos.set(interaction.user.id, list);
    await interaction.reply({ content: `Added. You have ${list.length} item(s).`, ephemeral: true });
  } else if (sub === 'list') {
    const text = list.length
      ? list.map((it, i) => `${i + 1}. ${it}`).join('\n')
      : 'Your list is empty.';
    await interaction.reply({ content: text, ephemeral: true });
  }
}

Users type /todo add or /todo list; getSubcommand() returns 'add' or 'list' and you handle each. (This Map lives in memory and resets when the server restarts — see Store data for your bot to make it stick.)

Limits worth knowing

Discord caps how big one command can get, and the caps are all the same friendly number — 25:

  • 25 options on a command (or on a single subcommand).
  • 25 choices on a string, integer, or number option — the fixed list a user picks from with .addChoices(...).
  • 25 subcommands under one command.

Cross a limit and the command fails to register on ClientReady; the console prints the validation error. The caps are generous, so brushing against one usually means it's time to split the work across more commands or subcommands.

The three-second rule

⚠️ Heads up: Discord expects an answer within about three seconds.

A normal reply() is instant, so most commands are fine. But when a command does slow work — a database call, an external API — acknowledge first, then fill in the answer:

await interaction.deferReply();
const data = await somethingSlow();
await interaction.editReply(`Done: ${data}`);

deferReply() shows a "thinking…" state and buys you time; editReply() delivers the real result once it's ready.

Troubleshooting

  • A command's new options don't show up — you changed the builder but didn't restart. The command list is only sent on ClientReady; restart, then allow first-time global changes a few minutes.
  • "This interaction failed" in Discord — your handler didn't reply within three seconds, or threw before replying. Read the console; deferReply() if the work is slow.
  • A button does nothing — the isButton() branch is missing or your customId action doesn't match. Handle buttons first, and check the string you split.
  • getString/getUser returns null — that option was optional and left out. Mark it setRequired(true), or handle the null (like loud ?? false).

Next steps

Was this guide helpful?