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.readdirSyncreading 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:fsandnode:pathwith thenode:prefix — they're Node's own built-in modules, so there's nothing to add topackage.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:
- Create
commands/yourcommand.jsexportingdataandexecute. - Restart. The loader picks up the new file,
ready.jsre-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:
requirecaches 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 modulepointing at a command file — a typo in arequire, or the file has a syntax error. The loaderrequires 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
dataandexecute(the loader logsSkipping ...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'— thecommands/(orevents/) folder doesn't exist next toindex.js.fs.readdirSyncneeds 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. Thetry/catchininteractionCreate.jsreplies "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
- Crash-proof your bot — grow that
try/catchinto real error handling - One bot, many servers — per-guild settings once you're organised
- discord.py cogs — the same idea in Python
- The discord.js handler guide goes deeper at discord.js.org