Structure a discord.js bot past one file

One index.js is fine for /ping — add ten commands and it's a wall. Here's the command-handler pattern discord.js bots grow into: a commands/ folder, an events/ folder, and a loader that wires them up automatically.

The discord.js bot guide put everything in one index.js, and for a /ping bot that's exactly right. Add ten commands, though, and that file becomes a wall you scroll — one giant InteractionCreate with a branch per command, one giant set([...]) array to keep in sync. This guide restructures the bot into the layout almost every real discord.js project uses: a commands/ folder where each command is its own file, an events/ folder for handlers, and a small loader in index.js that discovers and wires them up on startup.

At a glance
You need comfort with the discord.js bot guide, a bot token from Your first Discord bot, and a Falix server running the Node.js application
Plan Free or premium — free runs while your session timer has time, premium runs 24/7
Time about twenty-five minutes

This is the JavaScript sibling of discord.py cogs — the same "split into files, load them at startup" idea, in the shape discord.js expects.

The shape: a folder per job

index.js
commands/
  ping.js
  roll.js
events/
  ready.js
  interactionCreate.js

index.js shrinks to a loader: it reads every file in commands/ and events/ and registers what it finds. Each command lives in its own file that exports two things — its data (the slash-command definition) and its execute function (what runs when someone uses it). No more editing two places for one command; the command is the file.

🎯 Good to know: This is a plain folder convention, not a framework. There's nothing to install and no config — fs.readdirSync reading a directory is all the "magic" there is.

A command file: commands/ping.js

Every command file exports the same two-key object. data is the SlashCommandBuilder you met in Slash commands in depth; execute receives the interaction:

const { SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Check that the bot is alive'),
  async execute(interaction) {
    await interaction.reply(`Pong! ${interaction.client.ws.ping} ms`);
  },
};

A second command is a second file — commands/roll.js — with the exact same shape:

const { SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('roll')
    .setDescription('Roll a six-sided die'),
  async execute(interaction) {
    await interaction.reply(`🎲 You rolled a ${Math.floor(Math.random() * 6) + 1}`);
  },
};

The loader in index.js

Here's the whole index.js. It builds the client, reads commands/ into a Collection (discord.js's souped-up Map), reads events/ and attaches each handler, then logs in:

require('dotenv').config();
const fs = require('node:fs');
const path = require('node:path');
const { Client, Collection, GatewayIntentBits, Events } = require('discord.js');

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

// Load every command file into client.commands, keyed by command name.
client.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands');
for (const file of fs.readdirSync(commandsPath).filter(f => f.endsWith('.js'))) {
  const command = require(path.join(commandsPath, file));
  if ('data' in command && 'execute' in command) {
    client.commands.set(command.data.name, command);
    console.log(`Loaded command: ${command.data.name}`);
  } else {
    console.warn(`Skipping ${file}: missing data or execute`);
  }
}

// Load every event file and attach it to the client.
const eventsPath = path.join(__dirname, 'events');
for (const file of fs.readdirSync(eventsPath).filter(f => f.endsWith('.js'))) {
  const event = require(path.join(eventsPath, file));
  if (event.once) client.once(event.name, (...args) => event.execute(...args, client));
  else client.on(event.name, (...args) => event.execute(...args, client));
  console.log(`Loaded event: ${event.name}`);
}

client.login(process.env.DISCORD_TOKEN);

fs.readdirSync(commandsPath) lists the files in the folder; .filter(f => f.endsWith('.js')) keeps just the JavaScript ones. For each, require(...) imports it, the 'data' in command check skips anything that isn't a real command (a helper file, a work in progress), and client.commands.set(name, command) files it under its name so a handler can look it up later.

💡 Tip: Use node:fs and node:path with the node: prefix — they're Node's own built-in modules, so there's nothing to add to package.json.

The event files

Events move into their own files too, each exporting a name, an optional once, and an execute. events/ready.js registers your commands with Discord — and because the loader already collected them into client.commands, it builds the registration list from there instead of a hand-kept array:

const { Events } = require('discord.js');

module.exports = {
  name: Events.ClientReady,
  once: true,
  async execute(client) {
    await client.application.commands.set(
      client.commands.map(c => c.data.toJSON())
    );
    console.log(`Listening as ${client.user.tag}`);
  },
};

events/interactionCreate.js looks up the right command by name and runs its execute, wrapped in a try/catch so one broken command can't take the bot down:

const { Events } = require('discord.js');

module.exports = {
  name: Events.InteractionCreate,
  async execute(interaction, client) {
    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 reply = { content: 'Something went wrong.', ephemeral: true };
      if (interaction.replied || interaction.deferred) await interaction.followUp(reply);
      else await interaction.reply(reply);
    }
  },
};

This single handler now serves every command — client.commands.get(interaction.commandName) finds the file that owns the command and calls its execute. You never touch this file again as you add commands. (That try/catch is the start of proper crash-proofing — Crash-proof your bot takes it the rest of the way.)

Verify it works

Start the server. The console prints the loader's progress, then the ready line:

Loaded command: ping
Loaded command: roll
Loaded event: interactionCreate
Loaded event: clientReady
Listening as YourBot#0000

Those Loaded ... lines are proof the folders were read and everything registered before login. In Discord, both /ping and /roll work — after the usual first-time delay for new global commands.

Adding a command is now one file

The whole point of this layout:

  1. Create commands/yourcommand.js exporting data and execute.
  2. Restart. The loader picks up the new file, ready.js re-registers the full command list, and your command appears (allow the first-time delay).

No editing index.js, no keeping a registration array in sync, no growing InteractionCreate branch. Adding an event is the same move in events/.

🎯 Good to know: require caches modules, so a changed command file isn't reloaded until the process restarts. On Falix that's a feature, not a limit — edit the file, hit Restart, and every command reloads cleanly from the loader. There's no interactive shell here to hot-reload from anyway.

Troubleshooting

  • Cannot find module pointing at a command file — a typo in a require, or the file has a syntax error. The loader requires each file, so a broken one throws on startup and names itself. Read the console; the first error is the real one.
  • A command doesn't appear in Discord — either its file doesn't export both data and execute (the loader logs Skipping ... when a key is missing), or it's the normal first-time global delay. Check the startup log, then wait a few minutes.
  • ENOENT: no such file or directory, scandir '.../commands' — the commands/ (or events/) folder doesn't exist next to index.js. fs.readdirSync needs a real folder; create it, even if it's empty to start.
  • Bot online but a command errors — that's inside the command's own execute. The try/catch in interactionCreate.js replies "Something went wrong" and logs the stack to the console. Read the trace, fix that one file, restart.
  • Everything loads, then TokenInvalid — the loader ran fine; only the login failed. Fix the token in .env. See Bot appears offline.

Next steps

Was this guide helpful?