How to Add Slash Commands to Discord Bot — discord.js v14 Tutorial
Learn how to add slash commands to your Discord bot using discord.js v14 — step-by-step setup, registration, and error-proof handling included.
August 30, 2026 · 833 views
Adding slash commands to your Discord bot isn’t just a nice-to-have feature—it’s now essential for modern bot UX. Since Discord deprecated legacy message-based commands in favor of slash commands (and enforced global command registration via the API), understanding how to properly implement them with discord.js v14 is critical for any bot developer. Whether you're building a moderation tool, utility bot, or custom game assistant, this tutorial walks you through everything—from initial setup and command registration to dynamic options, permissions, and error resilience. ✅
We’ll use TypeScript-friendly JavaScript (ESM), assume Node.js ≥18.17, and follow Discord’s current best practices—including guild-specific vs. global command deployment, interaction replies, and proper caching hygiene. Let’s get started! 🚀
Prerequisites & Environment Setup
Before diving into slash commands, ensure your project meets these requirements:
- Node.js v18.17+ (v20.x recommended)
- discord.js v14.15.3+ (check
npm list discord.js) - A verified Discord Developer Application with a bot token
- Your bot invited with the
applications.commandsscope (e.g.,https://discord.com/api/oauth2/authorize?client_id=YOUR_ID&permissions=0&scope=bot%20applications.commands)
💡 Pro tip: Use dotenv to manage your token securely—never hardcode it!
npm init -y
npm install discord.js@^14.15.3 @discordjs/rest@^2.10.0 discord-api-types@^0.36.0
npm install --save-dev typescript @types/node @types/dotenv
Then initialize TypeScript (tsc --init) and configure "module": "ESNext", "target": "ES2020", and "moduleResolution": "node".
Step-by-Step: Registering Slash Commands
Unlike v13, discord.js v14 requires explicit command registration via the REST API, not automatic discovery. You’ll need two key pieces: a command structure (JSON-compatible) and a registration script.
1. Define Your Slash Command Structure
Create a commands/ping.js file:
// commands/ping.js
export const data = {
name: 'ping',
description: 'Replies with Pong! and latency info',
// Optional: restrict to specific guilds during development
// guild_ids: ['123456789012345678']
};
export async function execute(interaction) {
await interaction.reply({
content: `🏓 Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`,
ephemeral: true
});
}
✅ This pattern separates structure (data) from logic (execute)—making it scalable and testable.
2. Load Commands Dynamically
In your main bot file (index.js or bot.js), load all command files:
import { REST } from '@discordjs/rest';
import { Routes } from 'discord.js';
import fs from 'fs';
import path from 'path';
const commands = [];
const commandsPath = path.join(process.cwd(), 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const command = await import(`./commands/${file}`);
commands.push(command.data);
}
3. Register Commands Using REST
Use this one-time registration script (deploy-commands.js). Run it whenever commands change:
import { REST } from '@discordjs/rest';
import { Routes } from 'discord.js';
import { clientId, token } from './config.js';
const rest = new REST({ version: '10' }).setToken(token);
// ⚠️ Global commands take up to 1 hour to propagate
// For faster iteration, register to a test guild first
const guildId = 'YOUR_TEST_GUILD_ID'; // optional
const commands = /* … load commands as above … */;
(async () => {
try {
console.log(`Started refreshing ${commands.length} application (/) commands.`);
const data = guildId
? await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commands }
)
: await rest.put(Routes.applicationCommands(clientId), { body: commands });
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
console.error(error);
}
})();
Run with: node deploy-commands.js
📌 Tip: Always log data.length — if it returns 0, check file imports, .js extensions, or missing export const data.
Handling Interactions in Your Main Bot
Once registered, your bot must listen for interactionCreate events and route them:
import { Client, GatewayIntentBits, Collection } from 'discord.js';
import fs from 'fs';
import path from 'path';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.commands = new Collection();
const commandsPath = path.join(process.cwd(), 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const command = await import(`./commands/${file}`);
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.warn(`[WARNING] The command at ./commands/${file} is missing 'data' or 'execute'.`);
}
}
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
await interaction.followUp({
content: 'There was an error while executing this command!',
ephemeral: true
});
}
});
client.login(process.env.TOKEN);
✅ Note: We use interaction.isChatInputCommand() to filter only slash commands—not context menus or modals.
Advanced Slash Command Features
Adding Options & Validation
Here’s a /say command with string and boolean options:
// commands/say.js
export const data = {
name: 'say',
description: 'Make the bot say something',
options: [
{
type: 3, // String
name: 'text',
description: 'Text to say',
required: true,
},
{
type: 5, // Boolean
name: 'ephemeral',
description: 'Make response visible to you only?',
required: false,
},
],
};
export async function execute(interaction) {
const text = interaction.options.getString('text');
const ephemeral = interaction.options.getBoolean('ephemeral') ?? false;
await interaction.reply({
content: `> ${text}`,
ephemeral
});
}
🔧 LSI keywords reminder: This is where Discord server admins appreciate precise control—and users love intuitive, self-documenting interfaces.
Subcommands & Groups
discord.js v14 supports nested structures. Example: /math add 5 3:
export const data = {
name: 'math',
description: 'Perform math operations',
options: [
{
type: 1, // Subcommand
name: 'add',
description: 'Add two numbers',
options: [
{ type: 10, name: 'a', description: 'First number', required: true },
{ type: 10, name: 'b', description: 'Second number', required: true },
],
},
],
};
export async function execute(interaction) {
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'add') {
const a = interaction.options.getNumber('a');
const b = interaction.options.getNumber('b');
await interaction.reply(`${a} + ${b} = ${a + b}`);
}
}
Permissions & Default Member Permissions
You can restrict slash commands to roles or permissions using defaultMemberPermissions. Example:
defaultMemberPermissions: '0', // No permissions → only admins
// Or allow only users with Manage Messages:
defaultMemberPermissions: '16384', // bitfield for ManageMessages
⚠️ Important: defaultMemberPermissions accepts a bitfield string, not numeric. Use Discord’s Permission Calculator to generate it.
Also remember: dm_permission: false prevents use in DMs (useful for moderation bots).
Quick Tips
- 🌐 Global vs Guild Commands: Prefer guild-specific commands during development—they update instantly (<1 sec) vs. global (1h cache). Switch to global only when stable.
- 🧹 Clean Old Commands: To remove stale commands, redeploy only the current list. Discord automatically deletes unlisted ones.
- 📦 Use DiscordCraft for scaffolding: DiscordCraft offers CLI templates, auto-generated TypeScript command loaders, and built-in permission helpers—great for teams scaling beyond 10+ commands.
- 🛑 Always validate option types:
interaction.options.getString()throws if the option doesn’t exist or isn’t a string—wrap in try/catch or use??fallbacks. - 🕒 Rate Limits: Avoid rapid command re-registration—REST API enforces 2 requests/sec globally per app.
FAQ
Q: Why isn’t my slash command showing up?
A: Double-check (1) bot has applications.commands scope, (2) you ran deploy-commands.js, (3) command name is lowercase + no spaces, (4) no typos in clientId/token, and (5) you waited ~1 hour for global commands (or used a guild ID for instant testing).
Q: Can I have both slash and prefix commands?
A: Yes—but keep logic separate. Listen for messageCreate and interactionCreate, and avoid duplicate features unless UX demands it (e.g., /help + !help).
Q: How do I handle autocomplete?
A: Use interaction.isAutocomplete() and interaction.respond([...]) with up to 25 choices. We’ll cover that in depth in our next tutorial! 💡
Final Thoughts
Slash commands are the backbone of modern Discord bot interactions—and mastering them in discord.js v14 unlocks reliability, scalability, and official Discord compliance. With clean separation of concerns, robust error handling, and smart registration strategies, your bot will feel professional, responsive, and maintainable.
If you’re building more than a few commands, consider adopting conventions like modular handlers, TypeScript interfaces for CommandData, and automated linting for option consistency. And remember: every great Discord server starts with a well-behaved bot. 🤖✨
Need boilerplate? Check out DiscordCraft’s v14 Starter Kit — pre-configured with ESM, TypeScript, ESLint, and CI-ready deployment scripts. Happy coding!