This walkthrough takes you from an empty folder to a Discord bot that answers slash commands, and then covers what it takes to keep that bot running when your laptop is closed. Everything uses discord.js v14 (14.27.0 is the current release on npm at the time of writing) and CommonJS, so you can paste the snippets straight into a Node project.
By the end you will have:
- a Discord application with a bot user and a token kept out of your code
- a small script that registers slash commands through Discord's REST API
- a bot that handles
/pingand/roll, including basic error handling - a plan for running it 24/7
Prerequisites
-
Node.js: discord.js 14.27.0 declares
node >= 18in itspackage.json. Use a currently maintained LTS release. - Comfort with
async/awaitand basic JavaScript. - A Discord server where you have the Manage Server permission. A private server just for development is ideal.
1. Create the application in the Developer Portal
- Go to the Discord Developer Portal and create a new application. The name you pick becomes your bot's name.
- On General Information, copy the Application ID. You'll need it as
CLIENT_IDlater. - Open the Bot page and click Reset Token. Copy the token right away: Discord won't show it again unless you regenerate it.
Treat the token like a password. Anyone who has it can log in as your bot. Never paste it into source files, screenshots or public repos. If it leaks, go back to the Bot page and hit Reset Token immediately; the old one stops working.
2. Set up the project
mkdir my-bot && cd my-bot
npm init -y
npm install discord.js dotenv
Create a .env file in the project root:
DISCORD_TOKEN=paste-your-bot-token-here
CLIENT_ID=your-application-id
GUILD_ID=your-dev-server-id
To get GUILD_ID, turn on Developer Mode in Discord (User Settings → Advanced), right-click your server's name and choose Copy Server ID.
Then make sure the file never ends up in Git:
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
Tip: On Node 20.6+ you can skip
dotenvand runnode --env-file=.env index.jsinstead. This tutorial sticks withdotenvbecause it behaves the same everywhere, including under a process manager.
3. Define your commands
Slash commands are described with SlashCommandBuilder. Keep the definitions in their own file so the deploy script and the bot can share them later.
commands.js:
const { SlashCommandBuilder } = require('discord.js');
module.exports = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong!'),
new SlashCommandBuilder()
.setName('roll')
.setDescription('Rolls a die')
.addIntegerOption((option) =>
option
.setName('sides')
.setDescription('Number of sides (default: 6)')
.setMinValue(2)
.setMaxValue(100),
),
];
/roll has an optional integer option. Discord validates setMinValue/setMaxValue before the interaction reaches your bot, so users can't submit sides: 1 or sides: 9000.
4. Register the commands with REST and Routes
Commands don't appear in Discord until you register them. You only need to do this when a command's definition changes (name, description, options), not every time the bot starts. Discord has a daily limit on command creations, so the discord.js guide recommends a standalone script using the lightweight REST client instead of doing it inside the ready event.
deploy-commands.js:
require('dotenv').config();
const { REST, Routes } = require('discord.js');
const commands = require('./commands');
const { DISCORD_TOKEN, CLIENT_ID, GUILD_ID } = process.env;
const rest = new REST().setToken(DISCORD_TOKEN);
(async () => {
try {
const body = commands.map((command) => command.toJSON());
console.log(`Registering ${body.length} slash command(s)...`);
// PUT replaces the full set of guild commands with this list
const data = await rest.put(
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
{ body },
);
console.log(`Registered ${data.length} command(s) in guild ${GUILD_ID}.`);
} catch (error) {
console.error(error);
}
})();
A few things worth knowing:
-
rest.putoverwrites the whole command list for that route. If you remove a command fromcommands.jsand redeploy, it disappears from Discord. -
Routes.applicationGuildCommands(clientId, guildId)registers commands in one server. That's what you want during development. - When you're ready to make commands available everywhere the bot is installed, switch to
Routes.applicationCommands(clientId). Global commands also show up in DMs by default. - If you register the same command both globally and in your dev guild, you'll see it twice there. Some people use a separate application (and token) for development to avoid this.
Run it after inviting the bot (step 6), because the applications.commands scope must be authorized in the guild first:
node deploy-commands.js
5. Write the bot
index.js:
require('dotenv').config();
const { Client, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Ready! Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
try {
if (interaction.commandName === 'ping') {
await interaction.reply('Pong!');
} else if (interaction.commandName === 'roll') {
const sides = interaction.options.getInteger('sides') ?? 6;
const result = Math.floor(Math.random() * sides) + 1;
await interaction.reply(`🎲 You rolled a **${result}** (d${sides})`);
}
} catch (error) {
console.error(error);
const message = { content: 'Something went wrong running that command.', flags: MessageFlags.Ephemeral };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(message);
} else {
await interaction.reply(message);
}
}
});
client.login(process.env.DISCORD_TOKEN);
What's going on here:
-
Intents tell Discord which gateway events to send you.
GatewayIntentBits.Guildsis the baseline: it keeps the guild, channel and role caches populated, and slash commands work with it alone. You don't need message intents to receive interactions. -
Events.ClientReadyfires once after login.readyClientis typed as a logged-in client, which matters if you move to TypeScript. -
Events.InteractionCreatefires for every interaction: slash commands, buttons, select menus, modals, autocomplete.isChatInputCommand()filters for slash commands. -
Ephemeral replies are set with
flags: MessageFlags.Ephemeral. Older tutorials useephemeral: true; the discord.js guide now uses the flag. - The
try/catchmeans a failing command logs an error and tells the user, instead of leaving them with "The application did not respond".
Need more than three seconds?
Discord expects an initial response within three seconds, or the interaction fails. If a command calls an API or a database, acknowledge it first with deferReply(), then fill in the answer with editReply(). After the initial response you have 15 minutes to edit or send follow-ups.
await interaction.deferReply();
const data = await fetchSomethingSlow();
await interaction.editReply(`Here you go: ${data}`);
Want to read messages too?
Slash commands don't need it, but if you later want to react to regular messages (a word filter, for example), you need GatewayIntentBits.GuildMessages plus the privileged GatewayIntentBits.MessageContent intent. Privileged intents must also be switched on under Bot → Privileged Gateway Intents in the portal. Request only what you actually use.
6. Invite the bot and try it
In the Developer Portal, open Installation:
- Under Install Link, select Discord Provided Link.
- Under Default Install Settings → Guild Install, add the scopes
applications.commandsandbot. - In the permissions list that appears, choose what your bot needs. For this tutorial, Send Messages is enough.
- Open the install link in your browser, pick your dev server and confirm.
Now register and start:
node deploy-commands.js
node index.js
You should see Ready! Logged in as YourBot#1234. Type /ping or /roll sides:20 in your server.
7. Keeping it online
As long as node index.js runs on your machine, the bot is online. Close the terminal or shut down the computer, and it goes offline. For 24/7 operation you need two things: a machine that's always on and something that restarts the process if it crashes.
Common setups:
- A VPS or home server with a process manager. pm2 is a popular choice for Node:
npm install -g pm2
pm2 start index.js --name my-bot
pm2 logs my-bot # follow the output
pm2 save # remember the process list
pm2 startup # prints a command that starts pm2 on boot
systemd works just as well if you prefer to stay with what the OS provides.
- A container. Package the bot in a Docker image and run it with a restart policy, either on your own server or on a container host:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
docker build -t my-bot .
docker run -d --name my-bot --restart unless-stopped --env-file .env my-bot
Add .env and node_modules to a .dockerignore so the token never gets baked into the image. Pass it at runtime instead, as above.
Whichever route you pick, a little graceful-shutdown handling avoids the bot dropping off mid-reply when the process manager or container runtime stops it:
async function shutdown(signal) {
console.log(`${signal} received, logging out...`);
await client.destroy();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Command doesn't appear | Bot was invited without applications.commands, or deploy-commands.js wasn't run (or failed; check its output). |
[DisallowedIntents] on startup |
You request a privileged intent that isn't enabled in the Developer Portal. |
| "The application did not respond" | No reply within three seconds. Use deferReply() for slow work, and check your logs for errors. |
TokenInvalid |
Wrong or reset token in .env, or .env isn't being loaded. |
| Command shows up twice | It's registered both globally and in your guild. |
Where to go from here
Once you have more than a handful of commands, one big if/else in index.js gets messy. The usual next steps are:
- one file per command (exporting
dataandexecute) plus a loader that reads thecommands/folder - the same pattern for events
- structured logging so you can find problems after the fact
- a database once you need to persist anything (levels, settings)
The discord.js guide covers command and event handlers in detail, and the API docs are the reference for every class used above.
The extended version of this guide, with a section on structuring a larger bot project, lives at botnexio.com. If you want to prototype embed layouts for your bot's replies, there's also a free Discord embed generator.
Not affiliated with Discord.
Top comments (0)