Discord Bots

How to Set Up a Discord Bot with Node.js & discord.js

Tech Setup5 min read
TS

Tech Setup

Reviewed July 23, 2026

How to Set Up a Discord Bot with Node.js & discord.js

How to Set Up a Discord Bot with Node.js & discord.js

Building a production-ready Discord bot requires more than just pasting a quick script to listen for chat messages. Modern Discord bot development relies on Slash Commands (Application Commands), strict gateway intent configurations, and structured event handling.

This guide walks you through setting up a scalable, event-driven Discord bot from scratch using Node.js, discord.js v14, and modern ES Modules (ESM).


Prerequisites

Before beginning, ensure you have the following installed on your local machine:

  • Node.js 18.x or 20.x (LTS recommended)
  • npm (comes packaged with Node.js)
  • A Discord account and a test server where you have administrative permissions

Step 1: Create the Discord Application & Bot User

Every Discord bot starts in the Discord Developer Portal. You must register an application to represent your bot's identity on Discord's servers.

1. Register the Application

  1. Navigate to the Discord Developer Portal.
  2. Click New Application in the top right corner.
  3. Give your application a name (e.g., DevSetupBot) and agree to the developer terms.

2. Configure the Bot User

  1. Select the Bot tab from the left sidebar.
  2. Click Add Bot and confirm the dialog.
  3. Under the Token section, click Reset Token and copy the generated token string immediately. Store this safely; it is your bot's private key. If leaked, anyone can hijack your bot.

3. Configure Gateway Intents

Scroll down on the Bot page to the Privileged Gateway Intents section.

Intents dictate what data Discord streams to your bot. To ensure high security and optimal resource usage, only enable what you need:

  • Presence Intent: Disabled (unless tracking user activities/statuses).
  • Server Members Intent: Disabled (unless building a moderation/welcome bot).
  • Message Content Intent: Disabled. Note: For slash commands, you do not need this intent. Discord handles command parsing on its side and sends structured payloads directly to your application.

For this setup, you can leave all privileged intents toggled off.

4. Invite the Bot to Your Server

To generate an invite link, use Discord's OAuth2 URL generator:

  1. Select the OAuth2 tab from the left sidebar, then click URL Generator.
  2. Under Scopes, select bot and applications.commands.
  3. Under Bot Permissions, select:
    • Send Messages
    • Use Slash Commands
    • Embed Links
  4. Copy the generated URL at the bottom of the page, paste it into your browser, and select your test server to authorize the bot.

Step 2: Initialize Your Node.js Project

We will use modern ECMAScript Modules (ESM) instead of CommonJS. ESM allows you to use clean import/export statements native to Node.js.

Initialize a new project and install the required dependencies:

mkdir my-discord-bot
cd my-discord-bot
npm init -y

Open package.json and add "type": "module" to enable ESM:

{
  "name": "my-discord-bot",
  "version": "1.0.0",
  "type": "module",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}

Now install discord.js alongside dotenv (for loading environment variables) and nodemon (for hot-reloading during development):

npm install discord.js dotenv
npm install --save-dev nodemon

Step 3: Project Structure

For long-term maintainability, avoid putting all your code into a single file. Organize your bot into logical directories for commands, event handlers, and deployment scripts.

Create the following file structure:

my-discord-bot/
├── .env
├── .gitignore
├── package.json
└── src/
    ├── index.js
    ├── deploy-commands.js
    ├── commands/
    │   └── utility/
    │       └── ping.js
    └── events/
        ├── ready.js
        └── interactionCreate.js

Environment Configuration

Create a .env file in your root directory to store sensitive tokens and configurations:

DISCORD_TOKEN=your_bot_token_here
CLIENT_ID=your_bot_application_id_here
GUILD_ID=your_test_server_id_here
  • DISCORD_TOKEN: The private token you copied in Step 1.
  • CLIENT_ID: Found on your application's General Information tab in the Developer Portal.
  • GUILD_ID: The ID of your test server. To find this, enable "Developer Mode" in Discord's App Settings (Advanced), right-click your server's name, and select Copy Server ID.

Create a .gitignore file to ensure you do not commit node modules or credentials to source control:

node_modules/
.env

Step 4: Write Your First Slash Command

Let's write a simple utility command: /ping. It will measure API latency and respond with a confirmation message.

Create src/commands/utility/ping.js:

import { SlashCommandBuilder } from 'discord.js';

export const data = new SlashCommandBuilder()
  .setName('ping')
  .setDescription('Replies with Pong and latency metrics!');

export async function execute(interaction) {
  const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true });
  const latency = sent.createdTimestamp - interaction.createdTimestamp;
  const apiLatency = Math.round(interaction.client.ws.ping);

  await interaction.editReply(
    `🏓 Pong!\n• Roundtrip Latency: **${latency}ms**\n• WebSocket Latency: **${apiLatency}ms**`
  );
}

Step 5: Implement the Event Listeners

Events drive everything in discord.js. Instead of coupling everything inside the client configuration, we will write individual event files.

1. Ready Event

The ready event fires once the bot successfully logs in.

Create src/events/ready.js:

import { Events, ActivityType } from 'discord.js';

export const name = Events.ClientReady;
export const once = true;

export function execute(client) {
  console.log(`Ready! Logged in as ${client.user.tag}`);
  
  // Set an optional presence status
  client.user.setPresence({
    activities: [{ name: 'with Node.js', type: ActivityType.Playing }],
    status: 'online',
  });
}

2. Interaction Create Event

The interactionCreate event catches commands run by users and executes their corresponding logic.

Create src/events/interactionCreate.js:

import { Events } from 'discord.js';

export const name = Events.InteractionCreate;

export async function execute(interaction) {
  // We only care about slash commands
  if (!interaction.isChatInputCommand()) return;

  const command = interaction.client.commands.get(interaction.commandName);

  if (!command) {
    console.error(`No command matching ${interaction.commandName} was found.`);
    return;
  }

  try {
    await command.execute(interaction);
  } catch (error) {
    console.error(`Error executing /${interaction.commandName}:`, error);
    
    const errPayload = { 
      content: 'There was an error while executing this command!', 
      ephemeral: true 
    };

    if (interaction.replied || interaction.deferred) {
      await interaction.followUp(errPayload);
    } else {
      await interaction.reply(errPayload);
    }
  }
}

Step 6: Deploy Slash Commands to Discord

Before commands show up in Discord, they must be registered via the Discord API.

During development, you should deploy commands specifically to your test server (GUILD_ID). Guild commands register instantly, whereas global commands can take up to an hour to cache across all servers.

Create src/deploy-commands.js:

import { REST, Routes } from 'discord.js';
import dotenv from 'dotenv';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const commands = [];
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath);

for (const folder of commandFolders) {
  const commandsPath = path.join(foldersPath, folder);
  const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
  
  for (const file of commandFiles) {
    const filePath = path.join(commandsPath, file);
    // Convert OS path to file:// URL for dynamic import in ESM
    const fileUrl = new URL(`file://${filePath}`);
    const command = await import(fileUrl);
    
    if ('data' in command && 'execute' in command) {
      commands.push(command.data.toJSON());
    } else {
      console.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
    }
  }
}

const rest = new REST().setToken(process.env.DISCORD_TOKEN);

(async () => {
  try {
    console.log(`Started refreshing ${commands.length} application (/) commands.`);

    // Deploy to a specific Guild (Development Environment)
    const data = await rest.put(
      Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
      { body: commands },
    );

    console.log(`Successfully reloaded ${data.length} application (/) commands.`);
  } catch (error) {
    console.error(error);
  }
})();

Step 7: The Bot Client Entrypoint

Now, we bring it all together. The entry point file reads all command and event modules, populates a mapping collections object, and boots the Gateway client connection.

Create src/index.js:

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client, Collection, GatewayIntentBits } from 'discord.js';
import dotenv from 'dotenv';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Initialize Client with GUILDS Gateway Intent
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

// Attach Commands Collection to Client instance
client.commands = new Collection();

// 1. Dynamic Command Loading
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath);

for (const folder of commandFolders) {
  const commandsPath = path.join(foldersPath, folder);
  const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
  
  for (const file of commandFiles) {
    const filePath = path.join(commandsPath, file);
    const fileUrl = new URL(`file://${filePath}`);
    const command = await import(fileUrl);
    
    if ('data' in command && 'execute' in command) {
      client.commands.set(command.data.name, command);
    } else {
      console.warn(`[WARNING] The command at ${filePath} is missing "data" or "execute".`);
    }
  }
}

// 2. Dynamic Event Loading
const eventsPath = path.join(__dirname, 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));

for (const file of eventFiles) {
  const filePath = path.join(eventsPath, file);
  const fileUrl = new URL(`file://${filePath}`);
  const event = await import(fileUrl);
  
  if (event.once) {
    client.once(event.name, (...args) => event.execute(...args));
  } else {
    client.on(event.name, (...args) => event.execute(...args));
  }
}

// Handle unexpected application errors gracefully
process.on('unhandledRejection', error => {
  console.error('Unhandled promise rejection:', error);
});

// Login using env token
client.login(process.env.DISCORD_TOKEN);

Step 8: Run Your Bot

First, deploy your commands to the Discord guild api:

node src/deploy-commands.js

You should see output confirming the successful loading of commands:

Started refreshing 1 application (/) commands.
Successfully reloaded 1 application (/) commands.

Now, boot your bot in development mode using the hot-reloading script:

npm run dev

Your console will output:

Ready! Logged in as DevSetupBot#XXXX

Go into your Discord server, type /ping, and press Enter. The bot will respond instantly with latency information!


Production Best Practices

Before deploying your bot to production, make sure to implement the following patterns:

1. Global Command Deployment

When deploying your bot to a public server, deploy commands globally instead of to a guild. In src/deploy-commands.js, swap the route execution to use the global endpoint:

const data = await rest.put(
  Routes.applicationCommands(process.env.CLIENT_ID),
  { body: commands },
);

2. Run with PM2

Do not run your node processes inside raw terminal multiplexers like Screen or tmux on remote servers. Use a process manager like pm2 to handle automated restarts, memory leak logging, and clustering control.

Install pm2 globally:

npm install pm2 -g

Start your bot:

pm2 start src/index.js --name "discord-bot"

3. Graceful Shutdown

Avoid abruptly killing WebSocket client operations. Clean up connections if Node receives termination events:

const shutdown = () => {
  console.log('Shutting down gracefully...');
  client.destroy();
  process.exit(0);
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);