Register Slash Commands and Buttons in Discord.js v14
Deploy guild or global slash commands with Discord.js v14, attach buttons to replies, and route button clicks by custom ID without hitting REST rate limits.
20 Oct 2025, 20:55 UTC

The problem and the useful takeaway
You are building a Discord bot with discord.js v14 and need slash commands that appear in the client, replies that include button components, and a reliable way to handle button clicks. Two common failures waste time: deploying global commands on every restart, which hits Discord REST rate limits and can leave commands invisible for up to an hour, and using the old v13 component classes (MessageActionRow, MessageButton), which throw errors in v14.
The practical approach is to register guild commands during development so they update instantly, store command definitions in one array shared by the deployer and the interaction handler, namespace button custom IDs (for example button:ping), and defer any operation that takes longer than three seconds.
Prerequisites
- Node.js 16.9.0 or later
- discord.js@14.x installed in your project
- A bot token and application (client) ID from the Discord Developer Portal
- Optionally, a guild ID for instant command updates during development
- The applications.commands OAuth2 scope when inviting the bot to a server
Enable intents only if your feature needs them. For command and button handling, the Guilds intent is typically sufficient. Message Content and Server Members intents are required only if you read message content or access the member cache.
1. Define and share command data
Keep command definitions in a ./commands folder so the same array is used by both the deployment script and the runtime handler.
const { SlashCommandBuilder } = require('discord.js');
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with a button'),
async execute(interaction) {
const row = new ActionRowBuilder()
.addComponents(
new ButtonBuilder()
.setCustomId('button:ping')
.setLabel('Pong')
.setStyle(ButtonStyle.Primary)
);
await interaction.reply({
content: 'Pong!',
components: [row],
ephemeral: true,
});
},
};
customId is the string Discord sends back when a user clicks a button. Keep it under 100 characters. Namespacing with a colon, such as button:ping, makes it easy to route multiple button types in one handler.
2. Deploy commands with the REST API
Run this script from your project terminal with node deploy-commands.js. It reads secrets from environment variables, so never hardcode tokens or IDs in source files.
const { REST, Routes } = require('discord.js');
const fs = require('node:fs');
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.CLIENT_ID;
const guildId = process.env.GUILD_ID;
const commands = [];
const commandFiles = fs.readdirSync('./commands').filter((file) => file.endsWith('.js'));
for (const file of commandFiles) {
const command = require(`./commands/${file}`);
commands.push(command.data.toJSON());
}
const rest = new REST({ version: '10' }).setToken(token);
(async () => {
try {
const route = guildId
? Routes.applicationGuildCommands(clientId, guildId)
: Routes.applicationCommands(clientId);
await rest.put(route, { body: commands });
console.log(`Deployed ${commands.length} commands${guildId ? ' to guild' : ' globally'}`);
} catch (error) {
console.error(error);
process.exit(1);
}
})();
Use the guild route while developing. Guild commands appear instantly in that server. Global commands use the applicationCommands route and can take up to one hour to propagate to all clients. Do not redeploy global commands on every bot restart during development.
| Scope | REST route | Propagation | Best for |
|---|---|---|---|
| Global | applicationCommands | Up to 1 hour | Production release |
| Guild | applicationGuildCommands | Instant | Development and testing |
3. Handle interactions in the bot client
Run the bot with node index.js. The process needs read access to the token file or environment variable; no root or administrator privileges are required.
const { Client, GatewayIntentBits, Events } = require('discord.js');
const fs = require('node:fs');
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.commands = new Map();
const commandFiles = fs.readdirSync('./commands').filter((file) => file.endsWith('.js'));
for (const file of commandFiles) {
const command = require(`./commands/${file}`);
client.commands.set(command.data.name, command);
}
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isChatInputCommand()) {
const command = client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: 'There was an error executing this command.',
ephemeral: true,
});
} else {
await interaction.reply({
content: 'There was an error executing this command.',
ephemeral: true,
});
}
}
return;
}
if (interaction.isButton()) {
const parts = interaction.customId.split(':');
const namespace = parts[0];
const action = parts[1];
try {
if (namespace === 'button' && action === 'ping') {
await interaction.update({
content: 'Button clicked.',
components: [],
});
}
} catch (error) {
console.error(error);
}
return;
}
});
client.login(process.env.DISCORD_TOKEN);
interaction.reply sends the initial response. interaction.update edits the original message and can remove components by passing an empty array. Ephemeral replies are visible only to the user who invoked the command. If your work takes longer than three seconds, call deferReply first, then editReply when finished, to avoid an Interaction failed message.
await interaction.deferReply();
// perform slow work here
await interaction.editReply({ content: 'Finished.' });
4. Expected checks
- After deployment, guild commands appear in the target server immediately. Global commands may take up to one hour.
- Invoking /ping shows the reply and the Pong button.
- Clicking the button runs the button handler and updates the message.
- An ephemeral reply invoked from a second account is invisible to that account.
- A slow operation wrapped in deferReply and editReply does not show Interaction failed.
- The console shows no unhandled promise rejections after forced errors.
5. Recovery, limitations, and rollback
Deployment changes application state, so rollback means re-running the deployment script with the previous command definitions or an empty array to remove commands.
- To clear guild commands, call rest.put on the guild route with { body: [] }.
- To clear global commands, call rest.put on the global route with { body: [] }.
- Interaction tokens expire after 15 minutes. deferReply resets that window from the time of deferral, not from the original interaction.
- Button customId is limited to 100 characters. Encode complex payloads efficiently or store state elsewhere and reference it by ID.
- Sharded clients must deploy commands once per application, not once per shard.
- Discord.js REST auto-retries on rate limits. Monitor rateLimited events if you deploy frequently.
Ephemeral replies cannot have new components added through followUp; plan the full button flow in the initial reply. Check results by invoking the command from a second account and confirming the button click updates the message without console errors.