DEV Community

Botnexio
Botnexio

Posted on Originally published at botnexio.com

Build a Discord bot with discord.js v14: app setup, your first slash command and keeping it online

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 /ping and /roll, including basic error handling
  • a plan for running it 24/7

Prerequisites

  • Node.js: discord.js 14.27.0 declares node >= 18 in its package.json. Use a currently maintained LTS release.
  • Comfort with async/await and 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

  1. Go to the Discord Developer Portal and create a new application. The name you pick becomes your bot's name.
  2. On General Information, copy the Application ID. You'll need it as CLIENT_ID later.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Tip: On Node 20.6+ you can skip dotenv and run node --env-file=.env index.js instead. This tutorial sticks with dotenv because 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),
    ),
];
Enter fullscreen mode Exit fullscreen mode

/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);
  }
})();
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing:

  • rest.put overwrites the whole command list for that route. If you remove a command from commands.js and 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

What's going on here:

  • Intents tell Discord which gateway events to send you. GatewayIntentBits.Guilds is 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.ClientReady fires once after login. readyClient is typed as a logged-in client, which matters if you move to TypeScript.
  • Events.InteractionCreate fires 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 use ephemeral: true; the discord.js guide now uses the flag.
  • The try/catch means 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}`);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Under Install Link, select Discord Provided Link.
  2. Under Default Install Settings → Guild Install, add the scopes applications.commands and bot.
  3. In the permissions list that appears, choose what your bot needs. For this tutorial, Send Messages is enough.
  4. Open the install link in your browser, pick your dev server and confirm.

Now register and start:

node deploy-commands.js
node index.js
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode
  docker build -t my-bot .
  docker run -d --name my-bot --restart unless-stopped --env-file .env my-bot
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 data and execute) plus a loader that reads the commands/ 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)