Discord.js Bot Tutorial
Published

Discord.js Bot Tutorial
Discord bots are the secret sauce behind servers that feel alive. They welcome new members, keep chats clean, post alerts, run polls, play games, and quietly automate the boring stuff so communities can focus on having fun. If you have ever wondered how those bots work, or you tried a “hello world” example and got stuck in setup purgatory, this Discord.js bot tutorial walks you from zero to a real, usable bot with slash commands, events, permissions, and deployment basics.
You will build a bot the way people actually run them: with environment variables, intent configuration, a small command system, and a few practical features you can extend. By the end, you will have a foundation you can ship to a server without feeling like it will break the moment someone types something unexpected.
Getting set up: Node.js, Discord Developer Portal, and project structure
Before writing code, you need three things working smoothly: Node.js, a Discord application (your bot), and a basic folder layout.
Prerequisites
Install or verify these:
- Node.js 18+ (recommended for modern Discord.js)
- A code editor (VS Code is common)
- A Discord account and a server where you have permission to add bots
Check Node:
node -v
npm -vCreate a Discord bot in the Developer Portal
- Go to the Discord Developer Portal and create a New Application.
- Open the application and navigate to Bot.
- Click Add Bot.
- Under Privileged Gateway Intents, enable what you need:
- Server Members Intent only if your bot requires member lists or join events at scale.
- Message Content Intent only if you plan to read message text content (not required for slash commands).
- Click Reset Token and copy your token.
- Treat it like a password. Never hardcode it into your code or commit it to Git.
Invite the bot to your server
In the Developer Portal:
- Go to OAuth2 then URL Generator.
- Select scopes:
botapplications.commands
- Select bot permissions. Start minimal and expand later. For this tutorial:
Send MessagesRead Message History
- Copy the generated URL, open it in your browser, and invite the bot to your server.
Initialize the project
Create a folder and install dependencies:
mkdir discordjs-bot
cd discordjs-bot
npm init -y
npm install discord.js dotenvSet your project to use ES modules (recommended for modern Node). In package.json add:
{
"type": "module"
}Create a .env file:
DISCORD_TOKEN=your_bot_token_here
CLIENT_ID=your_application_client_id_here
GUILD_ID=your_test_server_id_hereYour CLIENT_ID is the Application ID from the Developer Portal. Your GUILD_ID is your server ID (enable Developer Mode in Discord, then right click your server name and copy ID).
Suggested structure:
src/index.jsfor the bot clientsrc/commands/for slash commandssrc/events/for eventssrc/deploy-commands.jsto register commands
Your first Discord.js bot: client, intents, and a healthy startup
Discord.js is event-driven. Your bot logs in, listens for events, and responds to interactions or messages. The most common early mistake is using too many intents or missing the ones you need, so we will keep it minimal and intentional.
Create src/index.js:
import 'dotenv/config';
import { Client, GatewayIntentBits, Collection } from 'discord.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const client = new Client({
intents: [
GatewayIntentBits.Guilds
]
});
client.commands = new Collection();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load command files
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(f => f.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = (await import(filePath)).default;
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.warn(`[WARN] Command at ${filePath} is missing "data" or "execute".`);
}
}
client.once('ready', () => {
console.log(`Logged in as ${client.user.tag}`);
});
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
const msg = 'Something went wrong while executing that command.';
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: msg, ephemeral: true });
} else {
await interaction.reply({ content: msg, ephemeral: true });
}
}
});
client.login(process.env.DISCORD_TOKEN);This file does a few critical things that scale well:
- Uses GatewayIntentBits.Guilds which is required for slash commands.
- Loads commands dynamically from a folder.
- Handles errors gracefully so a single crash does not take down your bot.
Add a start script in package.json:
{
"scripts": {
"start": "node src/index.js",
"deploy:commands": "node src/deploy-commands.js"
}
}Slash commands in Discord.js: deploy, respond, and add options
Most modern Discord bots use slash commands instead of message prefix commands. They are discoverable, permission-aware, and do not require the Message Content intent in many cases.
Create your first command
Create src/commands/ping.js:
import { SlashCommandBuilder } from 'discord.js';
export default {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong and latency info.'),
async execute(interaction) {
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true });
const latency = sent.createdTimestamp - interaction.createdTimestamp;
await interaction.editReply(`Pong. Roundtrip latency: ${latency}ms`);
}
};Deploy commands to your server
Discord requires you to register commands. For development, register to a single guild to get near-instant updates.
Create src/deploy-commands.js:
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { REST, Routes } from 'discord.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const commands = [];
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(f => f.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = (await import(filePath)).default;
commands.push(command.data.toJSON());
}
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
try {
console.log('Deploying slash commands...');
await rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
{ body: commands }
);
console.log('Commands deployed successfully.');
} catch (error) {
console.error(error);
}Deploy and run:
npm run deploy:commands
npm startNow in your server, type /ping and select it from the command picker.
Add a command with options (practical and fun)
Create src/commands/say.js:
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
export default {
data: new SlashCommandBuilder()
.setName('say')
.setDescription('Make the bot say something in the current channel.')
.addStringOption(option =>
option
.setName('message')
.setDescription('What should I say?')
.setRequired(true)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction) {
const message = interaction.options.getString('message', true);
await interaction.reply({ content: 'Posted.', ephemeral: true });
await interaction.channel.send(message);
}
};This demonstrates two real-world ideas:
- Command options so your command is structured.
- Default permissions so not everyone can use it.
Redeploy commands after adding new ones:
npm run deploy:commandsEvents and useful features: welcome messages, logging, and clean replies
Commands are great, but server automation often happens through events. Discord.js bots can react to member joins, messages, reactions, voice updates, and more.
Add an event loader (optional but scalable)
Create an events folder and a simple loader pattern. Update src/index.js to load events:
// after loading commands
const eventsPath = path.join(__dirname, 'events');
if (fs.existsSync(eventsPath)) {
const eventFiles = fs.readdirSync(eventsPath).filter(f => f.endsWith('.js'));
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
const event = (await import(filePath)).default;
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}
}Welcome new members (with intent awareness)
To welcome members you need the GuildMemberAdd event. For many bots, you can receive it without enabling privileged intents, but if you want full member data reliably at scale you may need Server Members Intent. Start simple.
Create src/events/guildMemberAdd.js:
export default {
name: 'guildMemberAdd',
async execute(member) {
const channel = member.guild.systemChannel;
if (!channel) return;
await channel.send(`Welcome, ${member}! Please check the rules and introduce yourself.`);
}
};If you do not see this firing, check:
- The server has a System Channel configured.
- Your bot has permission to send messages there.
- You have the appropriate intent settings in the Developer Portal if required.
Make your bot replies feel professional
A few habits dramatically improve user experience and reduce support headaches:
- Use
ephemeral: truefor confirmations that only the user needs. - Use
deferReply()for work that takes longer than a second or two. - Validate input early and reply with helpful messages.
Example: a tiny “math” command with validation:
Create src/commands/add.js:
import { SlashCommandBuilder } from 'discord.js';
export default {
data: new SlashCommandBuilder()
.setName('add')
.setDescription('Add two numbers.')
.addNumberOption(o => o.setName('a').setDescription('First number').setRequired(true))
.addNumberOption(o => o.setName('b').setDescription('Second number').setRequired(true)),
async execute(interaction) {
const a = interaction.options.getNumber('a', true);
const b = interaction.options.getNumber('b', true);
const sum = a + b;
await interaction.reply({ content: `${a} + ${b} = ${sum}` });
}
};Permissions, intents, and security: what keeps Discord bots from getting banned
A Discord.js bot tutorial is not complete without the guardrails. Most bot issues are not fancy code problems, they are permissions, secrets, or misuse of intents.
Keep your token safe
Do this:
- Store tokens in
.env - Add
.envto.gitignore - Rotate tokens immediately if leaked
Add .gitignore:
node_modules
.envUse minimal intents
Intents control what events your bot receives. Fewer intents means less data and fewer compliance concerns.
A practical starting point for a slash-command-first bot:
GatewayIntentBits.Guildsonly
Add more only when needed, for example:
GuildMembersfor member-related events and featuresGuildMessagesandMessageContentfor traditional message-based commands and moderation scanning
Use minimal permissions
When you generate your invite link, avoid asking for admin unless you truly need it. Many servers will refuse bots that request excessive permissions.
Common permissions for basic bots:
- Send Messages
- Embed Links (if you send embeds)
- Read Message History
- Manage Messages (only if you are building moderation tools)
Handle rate limits and errors
Discord enforces rate limits. Discord.js helps, but you still want to avoid spammy loops and bulk operations.
Good patterns:
- Batch operations and add delays when iterating.
- Cache and reuse data where possible.
- Wrap API calls in try-catch and provide fallbacks.
Deploying your Discord.js bot: running 24/7 and keeping it maintainable
Your bot runs fine locally, but the real goal is uptime. You can host on a VPS, a PaaS, or even your own machine, but the same principles apply: keep the process alive, log errors, and update safely.
A simple production checklist
- Use Node.js LTS (18 or 20)
- Keep tokens in environment variables
- Use a process manager like PM2 on a VPS
- Centralize logs and watch for crashes
Deploy on a VPS with PM2 (common approach)
On your server:
git clone <your-repo>
cd <your-repo>
npm ciSet environment variables (example with a .env file copied securely) and then:
npm run deploy:commands
npx pm2 start src/index.js --name discord-bot
npx pm2 save
npx pm2 startupUseful PM2 commands:
pm2 logs discord-botpm2 restart discord-botpm2 status
Deploy commands: guild vs global
During development, guild command deployment is best because updates are fast. For public bots, you will eventually want global commands, but they can take longer to propagate.
A common workflow:
- Develop with
applicationGuildCommands - When stable, switch to
applicationCommandsfor global rollout
If you go global later, remember that command names and descriptions should be stable and clean. Users will judge your bot by how polished the command picker feels.
Keeping your bot maintainable as it grows
As your command list grows, structure matters. A few habits that prevent chaos:
- Group commands into subfolders (moderation, utilities, fun)
- Add a shared
configmodule for IDs and constants - Use a logger (even
consolewith timestamps is better than silence) - Write small helper functions for repeated patterns like permission checks
Conclusion: your next steps to build a real Discord bot
You now have a working Discord.js bot with slash commands, a scalable loader pattern, basic permissions, and event handling. That is enough to ship something useful and to keep extending it without rewriting everything next week.
Next steps that pay off quickly:
- Add a
/helpcommand that lists features and links to support. - Store settings per server using a database (SQLite, Postgres, or a simple key-value store).
- Build moderation tools like
/purge,/mute, and automod filters, but keep permissions tight. - Add rich embeds and buttons using Discord interactions.
If you want to turn this into a production-ready Discord bot, pick one feature your server actually needs and implement it end-to-end with validation, permissions, and clean UX. That is how you go from “a bot that runs” to “a bot people trust.”
Worth sharing?
Send this to someone who would find it useful.