A Discord bot can moderate a server, automate support, post alerts or connect a community to an API. But a modern bot is no longer a simple script reading messages that start with !. Today, the sound baseline is to use slash commands, request the minimum of intents and permissions, keep the token out of the code, and separate command registration from starting the bot.
This tutorial builds a real Node.js project with:
/ping, to check availability and latency;/server, to display some information about the server;/say, restricted to members allowed to manage messages;- a token stored outside the code;
- protection against mentions injected through
/say; - clean error responses and a controlled process shutdown;
- a per-server test deployment, then a global deployment in production.
The code follows discord.js 14.27.0, released in July 2026, and Node.js 24 LTS. Node.js recommends the Active LTS or Maintenance LTS branches for production, and the 20 branch no longer receives new releases. Always check the maintained Node.js versions and the published discord.js releases before a new installation.
What a Discord bot actually is
A Discord application can use two complementary channels:
- the Gateway, a persistent WebSocket connection that delivers events in real time, such as an interaction or a member joining;
- the HTTP API, used to register a command, send a message or modify a resource.
discord.js provides a JavaScript abstraction over these APIs. The Client maintains the Gateway connection; the REST object performs the HTTP calls. The official introduction to Discord bots describes this architecture.
One important point: only automate a bot account created in the Developer Portal. Discord forbids "self-bots", meaning the automation of a normal user account, and states that doing so can lead to account termination. See the official self-bot policy.
Prerequisites
You need a Discord account, a Discord test server where you can manage applications, Node.js 24 LTS, an editor such as Visual Studio Code, a terminal, and JavaScript basics: variables, functions and async/await.
Check your environment:
node --version
npm --version
discord.js 14.27.0 officially declares Node.js 18 or higher as its prerequisite. We nonetheless choose Node.js 24 LTS in this guide, for a simple reason: the 20 branch no longer receives new releases, and running an internet-facing bot on a branch that no longer gets security fixes is not sensible. Any recent 24 release will do.
1. Create the application in the Discord Developer Portal
Open the Discord Developer Portal, then:
- click New Application;
- give the application a name;
- accept the terms and confirm;
- in General Information, copy the Application ID;
- in Bot, customise the name and avatar if needed;
- in Bot, use Reset Token to generate the token and copy it once.
Discord now creates a bot user with every new application. The official walkthrough is detailed in Building your first Discord Bot.
The token is not a public identifier
The token allows anyone to connect as the bot. Whoever obtains it can use its rights on every server where it is installed.
Never place this token in index.js, in a screenshot, in a ticket or Discord message, in Git even on a private repository, or in an example published on a blog.
If it leaks, go straight back to Bot > Reset Token, then replace the value on your hosting. Deleting an old commit is not enough: the secret must be revoked.
2. Configure installation and permissions
On the Installation page of the Developer Portal:
- enable Guild Install;
- preferably use the Discord Provided Link;
- add the
applications.commandsandbotscopes; - grant only View Channels and Send Messages for this project;
- open the link, select your test server and authorise the application.
Do not grant Administrator "so that it works": that permission bypasses channel restrictions and gives far more power than needed.
Permission to use /say will be controlled at command level by ManageMessages. The bot itself only needs to see the channel and send a message there. This distinction applies the principle of least privilege.
Discord permissions and role hierarchy
Bot permissions are not always enough. To manage a role, kick a member or change a nickname, the bot's highest role must also sit above the target in the hierarchy. Discord details this mechanism in its official permissions reference.
3. Understand intents before coding
Gateway Intents determine which categories of events Discord sends to the bot. They reduce the data received and access to sensitive information.
This tutorial only uses GatewayIntentBits.Guilds. This non-privileged intent is enough to receive interactions from slash commands installed on servers. We need neither to read every message nor to download the full member list.
Three intents are privileged: GuildPresences, GuildMembers and MessageContent. They must be enabled in the Developer Portal and declared in the code. Discord may close the connection with code 4014 when a bot requests a privileged intent it is not allowed to use.
Since 10 June 2026, privileged intent review is no longer based on the number of servers but on the number of unique users who can see your application across all servers. Above 10,000, an access request becomes necessary. A bot present on fifty large servers can therefore reach that threshold. Up-to-date rules are in the Gateway documentation and the privileged intent review guide.
For a new bot, slash commands generally avoid MessageContent. Discord recommends them as an alternative to old prefix-based text commands.
4. Initialise the Node.js project
Create the project folder:
mkdir my-discord-bot
cd my-discord-bot
npm init -y
npm install [email protected]
mkdir src
We pin the discord.js version here to get a reproducible installation. Keep the package-lock.json file: on the server, npm ci will be able to install exactly the validated versions.
The final structure will be:
my-discord-bot/
├── src/
│ ├── commands.js
│ ├── deploy-commands.js
│ └── index.js
├── .env
├── .env.example
├── .gitignore
├── package-lock.json
└── package.json
package.json
Replace its contents with:
{
"name": "my-discord-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"deploy:guild": "node --env-file=.env src/deploy-commands.js",
"deploy:global": "node --env-file=.env src/deploy-commands.js --global",
"start": "node --env-file=.env src/index.js"
},
"dependencies": {
"discord.js": "14.27.0"
}
}
The engines field is our own choice here, not a discord.js constraint: it documents the Node branch the project is meant to run on and makes installation fail on an older version.
"type": "module" enables the modern import syntax. Node.js can load .env natively with --env-file, which avoids an extra dependency. The syntax and variable precedence are documented in the Node.js command-line reference.
.gitignore
node_modules/
.env
npm-debug.log*
.env.example
This file describes the required variables without holding any secret:
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_GUILD_ID=
.env
Then create your local copy:
DISCORD_TOKEN=paste_the_bot_token_here
DISCORD_CLIENT_ID=paste_application_id_here
DISCORD_GUILD_ID=paste_test_server_id_here
To copy the server ID, enable User Settings > Advanced > Developer Mode, then right-click the server icon and choose Copy Server ID.
Never publish the real .env. Run git status before your first commit: the .env file must not appear among tracked files.
5. Define the slash commands
Create src/commands.js:
import {
PermissionFlagsBits,
SlashCommandBuilder,
} from 'discord.js';
export const commands = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Measures the bot latency'),
new SlashCommandBuilder()
.setName('server')
.setDescription('Displays server information'),
new SlashCommandBuilder()
.setName('say')
.setDescription('Posts a message through the bot')
.addStringOption((option) =>
option
.setName('text')
.setDescription('Message to post')
.setRequired(true)
.setMaxLength(1000),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
];
export const commandsJSON = commands.map((command) => command.toJSON());
SlashCommandBuilder produces a JSON object compatible with the API. The /say restriction is therefore not just a visual check in our code: Discord also sets ManageMessages as the command's default permission. See the application commands rules.
6. Register the commands with Discord
The bot code and the command list are two different resources. Editing commands.js does not automatically update the Discord interface: the new definition has to be sent to the API.
Create src/deploy-commands.js:
import { REST, Routes } from 'discord.js';
import { commandsJSON } from './commands.js';
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const deployGlobally = process.argv.includes('--global');
if (!token || !clientId || (!deployGlobally && !guildId)) {
throw new Error(
'Required variables: DISCORD_TOKEN, DISCORD_CLIENT_ID' +
(deployGlobally ? '' : ' and DISCORD_GUILD_ID'),
);
}
const rest = new REST({ version: '10' }).setToken(token);
const route = deployGlobally
? Routes.applicationCommands(clientId)
: Routes.applicationGuildCommands(clientId, guildId);
try {
console.log(
`Registering ${commandsJSON.length} command(s) ` +
(deployGlobally ? 'globally' : `on server ${guildId}`),
);
const result = await rest.put(route, { body: commandsJSON });
console.log(`${result.length} command(s) registered.`);
} catch (error) {
console.error('Command registration failed:', error);
process.exitCode = 1;
}
During development, register the commands on your test server only:
npm run deploy:guild
The PUT request replaces all commands on that route. Do not put this deployment in index.js: rewriting the commands on every restart is pointless and mixes two distinct operations.
Once the bot is validated and ready to be installed on several servers:
npm run deploy:global
After any change to a name, description, option or permission, run the matching deployment command again.
7. Write the client and handle interactions
Create src/index.js:
import {
Client,
Events,
GatewayIntentBits,
MessageFlags,
} from 'discord.js';
const token = process.env.DISCORD_TOKEN;
if (!token) {
throw new Error('The DISCORD_TOKEN variable is missing.');
}
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.once(Events.ClientReady, (readyClient) => {
console.log(
`Connected as ${readyClient.user.tag} ` +
`on ${readyClient.guilds.cache.size} server(s).`,
);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
try {
switch (interaction.commandName) {
case 'ping': {
const gatewayLatency = Math.round(client.ws.ping);
const interactionLatency = Date.now() - interaction.createdTimestamp;
await interaction.reply({
content:
`Pong! Gateway: ${gatewayLatency} ms · ` +
`interaction: ${interactionLatency} ms`,
flags: MessageFlags.Ephemeral,
});
break;
}
case 'server': {
if (!interaction.inGuild()) {
await interaction.reply({
content: 'This command only works inside a server.',
flags: MessageFlags.Ephemeral,
});
break;
}
await interaction.reply({
content:
`Server: **${interaction.guild.name}**\n` +
`Members: **${interaction.guild.memberCount}**\n` +
`ID: \`${interaction.guild.id}\``,
flags: MessageFlags.Ephemeral,
});
break;
}
case 'say': {
if (!interaction.inGuild() || !interaction.channel?.isTextBased()) {
await interaction.reply({
content: 'No usable text channel.',
flags: MessageFlags.Ephemeral,
});
break;
}
const text = interaction.options.getString('text', true);
await interaction.channel.send({
content: text,
allowedMentions: { parse: [] },
});
await interaction.reply({
content: 'Message posted.',
flags: MessageFlags.Ephemeral,
});
break;
}
default:
await interaction.reply({
content: 'Unknown command.',
flags: MessageFlags.Ephemeral,
});
}
} catch (error) {
console.error(
`Error during /${interaction.commandName} ` +
`(interaction ${interaction.id}):`,
error,
);
const response = {
content: 'An internal error occurred. Please try again later.',
flags: MessageFlags.Ephemeral,
};
if (interaction.replied || interaction.deferred) {
await interaction.followUp(response).catch(console.error);
} else {
await interaction.reply(response).catch(console.error);
}
}
});
client.on(Events.Error, (error) => {
console.error('Discord client error:', error);
});
async function shutdown(signal) {
console.log(`${signal} received: shutting down the bot.`);
await client.destroy();
process.exit(0);
}
process.once('SIGINT', () => void shutdown('SIGINT'));
process.once('SIGTERM', () => void shutdown('SIGTERM'));
await client.login(token);
Why these technical choices?
GatewayIntentBits.Guildsis the only intent these commands need.interaction.isChatInputCommand()cleanly ignores other interaction types.getString('text', true)marks the option as required and returns a string.allowedMentions: { parse: [] }prevents a user from injecting an interpreted mention such as@everyone, a role or a user through/say.MessageFlags.Ephemeralmakes confirmations and errors visible only to the person who ran the command.SIGINTandSIGTERMlet the terminal or the host close the connection cleanly.- the user-facing message stays generic; error details go to the logs without exposing the stack trace on Discord.
8. Respect the three-second interaction limit
Discord invalidates the interaction if the bot does not send an initial response within three seconds. The interaction token then remains usable for fifteen minutes to edit the response or send follow-up messages. These deadlines are stated in Receiving and Responding to Interactions.
For a database or an API that may take more than three seconds, acknowledge immediately:
await interaction.deferReply({
flags: MessageFlags.Ephemeral,
});
const result = await slowOperation();
await interaction.editReply({
content: `Result: ${result}`,
});
Do not use reply() after deferReply(): finish with editReply() or followUp().
9. Run and test the bot locally
Register the commands first, then start the process:
npm run deploy:guild
npm start
The console should show something like:
Connected as MyBot#0000 on 1 server(s).
In Discord, test:
/ping: the response is private;/server: the name, member count and ID appear;/say text:Hellowith an authorised account;/saywith an account withoutManageMessages: the command should be unavailable by default;/say text:@everyone test: the text may appear, but no mention should be triggered.
Test on a separate server before production. Also check permissions channel by channel: a role may allow Send Messages globally while being denied in a specific channel.
10. Add a welcome message, only if needed
A member joining is not covered by the Guilds intent. To listen to guildMemberAdd, you have to enable Server Members Intent in Bot > Privileged Gateway Intents, add GatewayIntentBits.GuildMembers to the client, store the channel ID in WELCOME_CHANNEL_ID, and check that the bot can see the channel and write in it.
Client change:
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
],
});
Then add the event:
client.on(Events.GuildMemberAdd, async (member) => {
const channelId = process.env.WELCOME_CHANNEL_ID;
if (!channelId) return;
const channel = await member.guild.channels
.fetch(channelId)
.catch(() => null);
if (!channel?.isTextBased()) return;
await channel.send({
content: `Welcome ${member} to **${member.guild.name}**!`,
allowedMentions: { users: [member.id] },
});
});
A channel ID is more reliable than a lookup by name: several channels can have similar names, and a rename will not break the configuration.
Do not enable MessageContent, GuildMembers or GuildPresences "just in case". Every intent increases the data received and the blast radius of any compromise.
11. Host the bot around the clock on OuiPanel
A bot launched on your computer stops with the terminal, sleep mode or your internet connection. For a continuous service, use a supervised Node.js environment.
The OuiHeberg Discord bot hosting page advertises Node.js, automatic restart, backups, task scheduling and version switching from the panel. The OuiPanel documentation for discord.js covers uploading via the file manager or SFTP, choosing the startup file, and installing dependencies on first launch.
Prepare the upload
Upload src/, package.json, package-lock.json and optionally .env.example with no real values.
Do not upload node_modules/, which will be rebuilt for the server's system, the .git/ folder, or your local .env if you prefer to create it directly in the panel.
Recommended setup
- select Node.js 24 LTS;
- upload the files through the manager or SFTP;
- create
.envdirectly on the server with the three variables; - set
src/index.jsas the main file if the panel asks for a "Main File"; - let the npm installation run on first start;
- start the server and check the logs;
- enable a backup once the configuration is validated.
This project's start script loads .env. Slash commands only need deploying after a change to their schema: run npm run deploy:guild during testing or npm run deploy:global for release.
For current pricing and specifications, see the Node.js hosting page.
12. Security and production operations
Reduce privileges
- never grant
Administratorby default; - enable only the intents your code uses;
- restrict sensitive commands with
setDefaultMemberPermissions(); - also check server-side permissions for critical operations;
- avoid logging tokens, HTTP headers or unnecessary personal data.
Manage dependencies
Keep package-lock.json and install in production with:
npm ci --omit=dev
Before an update:
npm outdated
npm audit
npm audit is a useful signal, not an absolute guarantee. Read the release notes, test on a separate Discord server, then deploy. A backup does not replace a clean Git repository.
Respect rate limits
Discord applies per-route limits and a global HTTP limit. A 429 response means you must wait for the duration given by Retry-After or retry_after. discord.js queues common calls; do not bypass that mechanism with loops of parallel requests. Discord states a global limit of 50 HTTP requests per second per bot in its rate limits documentation.
Log what actually helps
For each command error, log at minimum the time, the command name, the interaction ID, the error type and a server-side stack trace.
Avoid keeping message content if it is not necessary. Define a log retention period and restrict access to them.
Monitor the process
A bot that is "online" is not necessarily working. Check that the process restarts after a controlled crash, that /ping answers, that latency does not drift over time, that errors and disconnections are visible in the logs, and that backups can actually be restored.
13. Troubleshooting: common errors
| Symptom or error | Likely cause | Fix |
|---|---|---|
An invalid token was provided | token missing, outdated or badly copied | check .env; if needed, reset the token in the Developer Portal |
Used disallowed intents or close code 4014 | privileged intent requested but not enabled or not allowed | remove the unnecessary intent or enable it under Bot, then check eligibility |
Cannot find package 'discord.js' | dependencies not installed | run npm ci or restart the installation from the panel |
| Commands do not appear | wrong Application ID, wrong Guild ID, or commands not deployed | check .env, then run npm run deploy:guild again |
Interaction failed | no initial response within three seconds | use deferReply(), then editReply() |
Missing Permissions or API code 50013 | missing bot permission or channel permission | check the role, the channel permissions and the hierarchy |
/say is not visible to a member | the member does not have ManageMessages | this is the expected behaviour; adjust the command permission if needed |
| The bot is offline on the hosting | process stopped, wrong Node version or wrong main file | select Node.js 24, src/index.js, then read the first error in the logs |
| Old commands still visible | schema not redeployed on the right route | run the matching guild or global deployment again |
| Responses are sent twice | several processes use the same token | stop the duplicate instances and keep one process per bot, unless you run a deliberate sharding setup |
Always start from the first error in the console. The messages that follow are often consequences of that same initial failure.
14. Grow the project without making it fragile
The switch router is deliberately readable for three commands. As the project grows:
- put each command in its own module with
dataandexecute; - load the modules at startup into a
Collection; - validate every input before calling an API or a database;
- use parameterised queries for SQL;
- add unit tests on the business logic;
- separate development and production environments;
- plan database migrations and their rollback;
- consider sharding only when the bot's size justifies it.
Good architecture is not about multiplying folders on day one. It is about isolating responsibilities at the point where they become hard to test or maintain.
Frequently asked questions
Can you create a Discord bot for free?
Yes. The Developer Portal, the Discord API, Node.js and discord.js can all be used for free. Running it locally costs nothing, but it depends on your computer. Permanent hosting becomes useful when the bot has to stay available without your machine.
Do you need to know how to code?
You need to understand JavaScript basics and asynchronous code to maintain a bot reliably. Pasting a token into an example may be enough to show "online", but not to secure, diagnose or evolve the service.
Why use slash commands rather than a prefix?
They are native in the Discord interface, validate options, display help automatically and often avoid the privileged MessageContent intent. They also have per-command configurable permissions.
Why is the bot online but not responding?
The Gateway connection can work while the commands have not been registered, the bot has no access to the channel, or the interaction handler throws an error. Check the console, test /ping, then verify the deployment route and channel permissions.
Do I have to redeploy the commands on every start?
No. Redeploy them only when their name, description, options or permissions change. The main process should only connect and handle interactions.
Do I need the Message Content intent?
Not for the slash commands in this guide. It only becomes relevant if a feature genuinely needs to read the content of ordinary messages and meets Discord's conditions.
Which hosting should I choose to start?
A managed Node.js environment suits a first bot: dependency installation, selectable Node version, console and restart are centralised. A VPS gives more control, but requires administering the system, updates, services and security.
Conclusion
You now have a modern Discord bot: slash commands registered separately, a single non-privileged intent, minimal permissions, the token out of the code, handled errors and a clean shutdown. This base is simple enough to understand and rigorous enough to host a database, an external API, a ticket system or moderation features.
Before going to production, run three checks again: .env is not tracked by Git, the bot has no unnecessary permission, and the commands have been tested with a non-administrator account. Then deploy on a supervised Node.js environment and genuinely watch the logs.
Sources and reference documentation
- Discord: Building your first Discord Bot
- Discord: Bots and apps overview
- Discord: Application Commands
- Discord: Receiving and Responding to Interactions
- Discord: Gateway and Gateway Intents
- Discord: Getting Started with Privileged Intent Review
- Discord: Permissions
- Discord: Rate Limits
- Discord Support: self-bots
- discord.js: 14.27.0 documentation
- discord.js: published releases
- Node.js: release schedule
- Node.js: --env-file option
- OuiHeberg: Discord bot hosting
- OuiPanel: hosting a discord.js bot
