1. Create the application
- Go to the Discord Developer Portal and press New Application.
- On General Information, copy the Application ID. You need it to register commands.
- Open Bot, press Reset Token, and copy the token. It is the bot's password; keep it private.
- Leave the privileged intents off for now. Only turn on Message Content Intent if your bot will read ordinary messages.
2. Invite it to your server
- OAuth2, then URL Generator.
- Tick
botandapplications.commands, and under bot permissions tick Send Messages. - Open the URL at the bottom and add the bot to your server.
3. Set up the project
You need Node.js 20 or newer (node -v); the current LTS release is the safe choice.
mkdir my-bot
cd my-bot
npm init -y
npm install discord.js dotenvCreate .env with the token and the Application ID:
BOT_TOKEN=paste-your-token-here
CLIENT_ID=paste-your-application-id-hereIf you use Git, put .env and node_modules/ in .gitignore before your first commit.
4. Define the commands
Create commands.js, the one place your commands are described:
const { SlashCommandBuilder } = require("discord.js");
module.exports = [
new SlashCommandBuilder().setName("hello").setDescription("Say hello"),
new SlashCommandBuilder()
.setName("roll")
.setDescription("Roll a die")
.addIntegerOption((o) => o.setName("sides").setDescription("How many sides").setMinValue(2)),
];5. Register them with Discord
Create deploy-commands.js:
require("dotenv").config();
const { REST, Routes } = require("discord.js");
const commands = require("./commands");
const rest = new REST().setToken(process.env.BOT_TOKEN);
rest
.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands.map((c) => c.toJSON()) })
.then((data) => console.log(`Registered ${data.length} commands`))
.catch(console.error);node deploy-commands.jsRun this again whenever you add or change a command. Not on every start: Discord limits how many commands you can create a day.
6. Write the bot
Create index.js:
require("dotenv").config();
const { Client, Events, GatewayIntentBits } = require("discord.js");
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (c) => {
console.log(`Logged in as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "hello") {
await interaction.reply(`Hello, ${interaction.user}!`);
}
if (interaction.commandName === "roll") {
const sides = interaction.options.getInteger("sides") ?? 6;
await interaction.reply(`You rolled ${1 + Math.floor(Math.random() * sides)}`);
}
});
client.login(process.env.BOT_TOKEN);Add a start script to package.json so it is one command to run, here and on any host:
"scripts": {
"start": "node index.js"
}dotenv reads .env when there is one and does nothing when there is not, so the same code works on a host that provides the token another way. (Node's own --env-file=.env flag stops with an error if the file is missing, which is a common surprise after deploying.)
7. Run it
npm startYou should see Logged in as and the bot turns green. Type / in your server and use /hello. If the commands are not listed, reload Discord with Ctrl+R.
8. Make new commands appear instantly while you test
The deploy script registers commands globally, for every server the bot is in, and changes can take a while to reach everyone's Discord. While you are building, register them to your own server instead, where they update the moment the script runs. Add your server's ID to .env:
GUILD_ID=paste-your-server-id-hereand change the route in deploy-commands.js:
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID)To find the ID, turn on Developer Mode in Discord (User Settings, Advanced), then right-click your server and choose Copy Server ID. When the bot is ready for other servers, switch back to Routes.applicationCommands, and run the server route once with { body: [] } to clear the test copy. Otherwise every command shows up twice in your server.
9. Reply to ordinary messages
Slash commands need nothing special. Reading what people type in chat does: it needs the Message Content intent, switched on in two places. In the Developer Portal, under Bot, turn on Message Content Intent. In index.js, ask for it, along with the intent that delivers server messages at all:
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return; // never answer bots, including yourself
if (message.content.toLowerCase() === "ping") {
await message.reply("pong");
}
});Without GuildMessages, the event never fires for server messages. Without MessageContent, it fires but message.content is an empty string for most messages, so the bot looks as if it ignores everyone. Asking for Message Content in code but not switching it on in the portal stops the bot at login with Used disallowed intents.
10. Send an embed
Embeds are the boxed messages with a title, fields and a colour bar. Add the command to the array in commands.js, importing InteractionContextType at the top next to SlashCommandBuilder:
new SlashCommandBuilder()
.setName("serverinfo")
.setDescription("Show some facts about this server")
.setContexts(InteractionContextType.Guild),and answer it in index.js, inside the InteractionCreate handler:
const { Colors, EmbedBuilder, time, TimestampStyles } = require("discord.js"); // at the top
if (interaction.commandName === "serverinfo") {
const guild = interaction.guild;
const embed = new EmbedBuilder()
.setTitle(guild.name)
.setColor(Colors.Blurple)
.addFields(
{ name: "Members", value: String(guild.memberCount), inline: true },
{ name: "Created", value: time(guild.createdAt, TimestampStyles.LongDate), inline: true },
);
const icon = guild.iconURL(); // null for a server without an icon
if (icon) embed.setThumbnail(icon);
await interaction.reply({ embeds: [embed] });
}setContexts(InteractionContextType.Guild) hides the command in DMs, where there is no server to describe and interaction.guild would be null. The command list changed, so run node deploy-commands.js again.
11. Add a button
Buttons sit in an action row under a message. This one counts its clicks and edits its own label. Add new SlashCommandBuilder().setName("counter").setDescription("A button that counts its clicks") to commands.js, deploy, then:
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } = require("discord.js"); // at the top
if (interaction.commandName === "counter") {
let count = 0;
const button = new ButtonBuilder()
.setCustomId("count")
.setLabel("Clicked 0 times")
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder().addComponents(button);
const reply = await interaction.reply({ content: "Press it:", components: [row] });
const collector = reply.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 180_000, // stops listening after 3 minutes
});
collector.on("collect", async (click) => {
count += 1;
button.setLabel(`Clicked ${count} times`);
await click.update({ components: [row] });
});
collector.on("end", () => {
button.setDisabled(true);
interaction.editReply({ components: [row] }).catch(console.error);
});
}A collector lives in the bot's memory, so the button stops working when its time runs out or the bot restarts; clicking it then shows This interaction failed, which is why this one greys itself out at the end. Buttons that must keep working for days are answered in the InteractionCreate handler instead, by checking interaction.isButton() and interaction.customId.
12. Keep one error from stopping the bot
discord.js emits the client's error event for connection and internal errors, and Node stops the bot if nothing listens for it. An error thrown inside your own async handler (a reply that came too late, a message that was deleted) is different: it is an unhandled promise rejection, and Node stops the whole bot on those too. Two lines in index.js turn both into log lines instead:
client.on(Events.Error, console.error);
process.on("unhandledRejection", console.error);That keeps the bot running, but the person who used the command still gets no answer. For that, wrap command code in try/catch and reply with an error message, as the slash commands guide does.
Slash commands or prefix commands?
Prefix commands are the older !roll 20 style. discord.js has no built-in system for them; you read each message and split it yourself:
const PREFIX = "!";
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot || !message.content.startsWith(PREFIX)) return;
const [name, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/);
if (name === "roll") {
const sides = Math.max(2, parseInt(args[0], 10) || 6);
await message.reply(`You rolled ${1 + Math.floor(Math.random() * sides)}`);
}
});They need the Message Content intent from step 9. A bot in fewer than 100 servers can switch it on itself; past 100, Discord grants it only after a review. Slash commands need neither, show up in Discord's command menu with their options, and are what new bots should use. Once a bot has more than a handful of commands, give each its own file: the slash commands guide shows the layout most discord.js bots use.
If it did not work
TokenInvalid: An invalid token was provided: the token is wrong, orBOT_TOKENis not set. Check.envis in the folder you ran the command from and the line is spelt exactlyBOT_TOKEN=.Cannot find module 'dotenv': runnpm install dotenvin the project folder.Used disallowed intents: your code asks for an intent that is switched off in the Developer Portal.- Commands never appear: the deploy script did not run,
CLIENT_IDis wrong, or the invite link was missingapplications.commands. - The bot ignores ordinary messages:
GuildMessagesorMessageContentis missing from the intents, or Message Content is off in the portal. See step 9. DiscordAPIError[10062]: Unknown interaction: the bot took more than three seconds to answer. For anything slow (an API, a database), callawait interaction.deferReply()first and answer witheditReply.- The bot stops on the first error: nothing is listening for the client's
errorevent. See step 12.
Where to go next
- Slash commands in discord.js: options, deferring slow replies, one file per command.
- Keeping a bot online 24/7: it stops when your terminal closes.
- Getting a discord.js bot ready for hosting.
- What people build next: welcome messages, reaction roles, kick, ban and timeout commands, a ticket system and a custom status, each with the whole code.
SnowServers runs discord.js bots from $3 a month with a 7-day free trial. Push this folder to GitHub (without .env), paste the repository link and your token into the panel, and npm start runs on its own.
Run it here. Flurry keeps a bot like this online all day for $3 a month, and the first week is free.
Start the free weekDid this page help?
Thanks for the feedback.