Discord bots in 2026 are interaction-first. The old prefix-command era (!help, !ban) is over for verified bots — Discord now requires slash commands, context menus, and modal forms delivered through its interaction system. The good news: discord.js v15 makes the wiring straightforward, and a minimal but production-capable bot takes less than an hour to ship.
What changed in 2026
- discord.js v15 is the stable release, targeting Node.js 20+ with full ESM support and a revamped
Client options API.
- Privileged intents require justification.
GuildMembers and MessageContent are privileged — enable only what you genuinely need or your bot won't pass verification.
- Application commands are the only supported public API. Slash commands, user context menus, and message context menus are all registered as
ApplicationCommand objects.
- Interactions have a 3-second acknowledgement window. Use
interaction.deferReply() for any async work; otherwise Discord shows "interaction failed."
Project setup
mkdir my-discord-bot && cd my-discord-bot
npm init -y
npm install discord.js@15
Create index.mjs:
import { Client, GatewayIntentBits, REST, Routes } from 'discord.js';
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.once('ready', () => console.log(`Logged in as ${client.user.tag}`));
client.login(process.env.DISCORD_TOKEN);
GatewayIntentBits.Guilds is the minimum required intent for slash commands. Add others only when you need them.
Registering slash commands
Define commands as plain objects matching the ApplicationCommandData shape:
const commands = [
{
name: 'ping',
description: 'Replies with the current gateway latency.',
},
{
name: 'echo',
description: 'Echoes your message.',
options: [
{ name: 'text', description: 'Text to echo', type: 3, required: true },
],
},
];
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
await rest.put(
Routes.applicationCommands(process.env.APP_ID),
{ body: commands },
);
console.log('Commands registered globally.');
Global registration propagates in up to ~1 hour. For development, use Routes.applicationGuildCommands(appId, guildId) — it applies instantly.
Handling interactions
const handlers = new Map([
['ping', async (i) => i.reply(`Pong! Latency: ${client.ws.ping}ms`)],
['echo', async (i) => i.reply(i.options.getString('text', true))],
]);
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const handler = handlers.get(interaction.commandName);
if (!handler) return interaction.reply({ content: 'Unknown command.', ephemeral: true });
try {
await handler(interaction);
} catch (err) {
console.error(err);
const payload = { content: 'Something went wrong.', ephemeral: true };
interaction.replied ? interaction.followUp(payload) : interaction.reply(payload);
}
});
The Map-based dispatcher keeps interactionCreate clean and makes each handler independently testable.
What changed in 2026
| Feature |
Pre-2024 |
2026 |
| Commands |
Prefix (!cmd) or slash |
Slash + context menus only (verified bots) |
| Framework |
discord.js v13/v14 |
discord.js v15 (ESM-first) |
| Node.js target |
16+ |
20+ |
| Intent gating |
Loose |
Strict — privileged require approval |
| Interaction timeout |
3s |
3s (defer for async) |
How to pick gateway intents
| Intent |
Need it when |
Guilds |
Any slash command or guild info — always |
GuildMessages |
Reading messages (requires approval after 100 servers) |
MessageContent |
Reading message body — privileged, avoid if possible |
GuildMembers |
Listing or watching member join/leave — privileged |
DirectMessages |
DM support |
Start with Guilds only. Add intents one at a time as your feature set demands.
How to start
- Create an application at discord.com/developers/applications.
- Under Bot, generate a token and enable only the intents your bot needs.
- Generate an OAuth2 URL with the
bot + applications.commands scopes.
- Set
DISCORD_TOKEN and APP_ID in your environment.
- Run your registration script once, then start the bot.
Common mistakes
Not deferring long-running handlers. Discord closes the interaction after 3 seconds. Call await interaction.deferReply() at the top of any handler that hits a DB or external API.
Registering commands on every boot. Global registration has a rate limit. Register once (or on a deploy hook), not inside client.once('ready').
Catching errors silently. An unhandled rejection inside an event listener crashes the process in Node 20. Always wrap handlers in try/catch and log.
Using outdated MessageEmbed. It was renamed EmbedBuilder in v14. v15 will throw at runtime if you use the old name.
What to skip
- Prefix command parsing — adds complexity and fails bot verification at scale.
- Heavy bot frameworks (Sapphire, etc.) until you understand the underlying discord.js primitives. They solve real problems at scale, but obscure the model early on.
- Storing the bot token in
.env committed to git — use a secrets manager or environment injection in CI.
FAQ
Do I need a server to run a Discord bot?
Yes for 24/7 uptime. A ~$5/month VPS or a free Railway/Render instance works fine for small bots. Discord bots use a persistent WebSocket, not HTTP polling.
How do I make commands guild-only vs. global?
Use Routes.applicationGuildCommands(appId, guildId) to register to one guild (instant) or Routes.applicationCommands(appId) for all guilds (up to 1-hour propagation).
Can I use TypeScript?
Yes — discord.js v15 ships full type definitions. Use ts-node or compile to ESM with tsc. The types for CommandInteraction and option accessors are especially helpful.
How do I handle button clicks and modals?
Use interaction.isButton() and interaction.isModalSubmit() checks inside interactionCreate. Store state in a Map keyed on interaction.customId.
Where to go next