Skip to content
CreateDiscordBot
Menu

How to create a custom Discord bot with discord.js (step by step)

Create a custom Discord bot with discord.js step by step: Developer Portal setup, token, intents, slash commands with options, deferReply and error handling.

By Adam Peleback. Updated . 15 min read.

On this page
  1. The custom Discord bot you'll create
  2. Before you start
  3. Step 1: Create the application and bot user
  4. Step 2: Copy the token into .env
  5. Step 3: Choose your intents
  6. Step 4: Invite the bot to your test server
  7. Step 5: Set up the project
  8. Step 6: Write the command loader
  9. Step 7: Add the commands
  10. Step 8: Register the commands with Discord
  11. Step 9: Handle interactions and errors
  12. Step 10: Run it
  13. Keep it online
  14. Going global
  15. Troubleshooting
  16. How I tested this code
  17. Where to go next

To create a custom Discord bot, you register an application in the Discord Developer Portal, copy its bot token, invite the bot to a server, and run a program that logs in with that token and answers slash commands. With discord.js 14 and Node.js, that program is six small JavaScript files. This guide gives you all of them, from an empty folder to a bot that answers /ping, runs /poll with options and fetches live data in /npm.

"Custom" here means code you own: the bot does exactly what you write, runs under your own name and avatar, and can work with your own data and rules. If you would rather click a bot together than write JavaScript, a no-code builder is the faster start, and Discord bot makers compared covers where those stop.

I tested every file on this page on September 27, 2026, with discord.js 14.27.0 on Node.js 24.15.0; the method is in how I tested this code.

The custom Discord bot you'll create

  • /ping replies with the roundtrip time to Discord and the gateway heartbeat.
  • /poll takes a question, a comma-separated list of answers, a duration and a multiple-choice switch, then posts a native Discord poll.
  • /npm looks up a package on the npm registry. It calls two outside APIs, so it shows how to use deferReply when a command needs more than 3 seconds.
  • A command handler that loads every file in a commands folder, so adding a command means adding a file.
  • A deploy script that registers the commands in your test server instantly, or globally when you're ready.
  • Error handling, so a failing command shows the member a short message instead of "The application did not respond".

The finished project looks like this:

my-discord-bot/
my-discord-bot/
├── commands/
│   └── utility/
│       ├── npm.js
│       ├── ping.js
│       └── poll.js
├── node_modules/
├── .env
├── .gitignore
├── deploy-commands.js
├── index.js
├── load-commands.js
├── package-lock.json
└── package.json

Before you start

You need three things:

  1. Node.js 22 or 24. Run node --version to check. discord.js 14.27 still accepts Node 18, but Node 18 and 20 stopped getting security fixes in April 2025 and April 2026. The --env-file flag this project uses to load secrets is built into Node and no longer experimental from 22.21 and 24.10.
  2. A Discord server for testing where you have the Manage Server permission. Create an empty one; don't develop in a live community.
  3. A code editor such as VS Code, and a terminal.

You should be comfortable with basic JavaScript: functions, async/await and objects. You don't need to know anything about Discord's API yet.

Step 1: Create the application and bot user

  1. Open the Discord Developer Portal and click New Application. Give it the name your bot should have and click Create.
  2. On General Information, copy the Application ID. That is your CLIENT_ID. You can also set the app's icon and description here.
  3. Open the Bot page. New applications already have a bot user, so there is nothing to create.

On the same page there is a Public Bot switch. When it is on, anyone with your invite link can add the bot to their server. For a bot built for one community, turn it off so only you can add it. If the portal refuses with "Private application cannot have a default authorization link", open the Installation page, set Install Link to None and try again. You'll build your own invite link in step 4.

Step 2: Copy the token into .env

Still on the Bot page, click Reset Token and copy the token. Discord shows it once; if you lose it, reset it again.

The token is the bot's password. Anyone who has it can log in as your bot and do anything its roles allow, so it never goes in your code or in a chat message. Create a folder called my-discord-bot and put the token in a file named .env inside it, together with two IDs:

.env
DISCORD_TOKEN=paste-your-bot-token-here
CLIENT_ID=your-application-id
GUILD_ID=your-test-server-id

To get GUILD_ID, turn on Developer Mode in Discord (User Settings, then Advanced, then Developer Mode), right-click your test server's icon and choose Copy Server ID.

Then create .gitignore next to it, so neither the token nor the installed packages end up in Git:

.gitignore
node_modules/
.env

If a token ever leaks, for example in a public repository or a screenshot, click Reset Token straight away. Resetting replaces the token, so the leaked copy is useless. My guide to Discord bot tokens covers storing one on a host and what else to do after a leak.

Step 3: Choose your intents

When a bot connects to Discord's gateway, it declares intents: the groups of events it wants to receive. Discord sends nothing else, which keeps traffic down and keeps private data away from bots that don't need it.

This bot uses one intent, Guilds. It gives discord.js the servers, channels and roles it needs to work. Slash commands don't need an intent at all: Discord always sends interactions. Add others only when a feature needs their events, for example GuildMessages to react to new messages or GuildVoiceStates for voice.

Three intents are privileged. You have to switch them on under Privileged Gateway Intents on the Bot page before your code can ask for them:

Privileged intent What it gives you Typical use
Server Members Member join, leave and update events, and the full member list Welcome messages, role sync, member counts
Presence Online status, activities and platforms "Now playing" roles, status dashboards
Message Content The text, embeds, attachments, components and polls of other people's messages Prefix commands, keyword filters, transcripts

Leave all three off for this bot. If your code requests one that is switched off in the portal, the connection fails with Error: Used disallowed intents.

The rules for getting them changed on June 10, 2026. An app that fewer than 10,000 unique users can see across all its servers just flips the switches. Above 10,000 users, Discord asks you to apply with your use case, and approved apps reapply every year. Older tutorials still quote the previous 100-server rule. Discord's guide You might not need a privileged intent is worth reading before you turn any of them on.

Step 4: Invite the bot to your test server

A bot joins a server through an authorization link, not a normal invite. The link names your application, the bot scope and the permissions the bot asks for:

invite URL
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&scope=bot&permissions=562949953440768

Replace YOUR_CLIENT_ID with your Application ID, open the link, pick your test server and approve. The bot appears in the member list, offline until you start it.

The permissions number is the sum of four permission bits:

Permission Why this bot asks for it
View Channels See the channels where commands are used
Send Messages Post messages in those channels
Embed Links Show the /npm result as an embed
Send Polls Post the poll that /poll creates

To add or remove permissions for your own commands, tick them in the permissions calculator, which builds the number and the invite link for you. You don't need to add the applications.commands scope: Discord includes it with bot, and it is what lets your bot register slash commands in the server.

Step 5: Set up the project

In my-discord-bot, create package.json:

package.json
{
  "name": "my-discord-bot",
  "private": true,
  "type": "commonjs",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "deploy": "node --env-file=.env deploy-commands.js",
    "deploy:global": "node --env-file=.env deploy-commands.js --global",
    "dev": "node --env-file=.env --watch index.js",
    "start": "node --env-file=.env index.js"
  },
  "dependencies": {
    "discord.js": "^14.27.0"
  }
}

Then install discord.js:

terminal
npm install

The scripts pass --env-file=.env to Node, which loads your token and IDs into process.env. That replaces the dotenv package many older tutorials install. The dev script adds --watch, which restarts the bot whenever you save a file.

Step 6: Write the command loader

Every command lives in its own file under commands/<category>/ and exports two things: data, the definition Discord shows in the command picker, and execute, the function that runs when someone uses it.

load-commands.js finds those files. Both the bot and the deploy script use it, so the list you register with Discord is always the list your bot can answer:

load-commands.js
const fs = require('node:fs');
const path = require('node:path');

// Reads every .js file in commands/<folder>/ and returns the command modules.
// index.js uses it to route interactions, deploy-commands.js to register them.
function loadCommands() {
  const commands = [];
  const root = path.join(__dirname, 'commands');

  for (const folder of fs.readdirSync(root)) {
    const folderPath = path.join(root, folder);
    if (!fs.statSync(folderPath).isDirectory()) continue;

    for (const file of fs.readdirSync(folderPath).filter((name) => name.endsWith('.js'))) {
      const command = require(path.join(folderPath, file));
      if (!command.data || typeof command.execute !== 'function') {
        throw new Error(`commands/${folder}/${file} must export "data" and "execute"`);
      }
      if (commands.some((other) => other.data.name === command.data.name)) {
        throw new Error(`Two files define /${command.data.name}`);
      }
      commands.push(command);
    }
  }

  return commands;
}

module.exports = { loadCommands };

It stops with a clear error when a file is missing an export or two files claim the same name, instead of failing later with a vague Discord error. Folders are only for your own organization; Discord never sees them.

Step 7: Add the commands

Create commands/utility/ and add the three files below.

/ping: check that the bot answers

commands/utility/ping.js
const { SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Check that the bot is online and how fast it answers'),

  async execute(interaction) {
    // withResponse returns the message Discord created, so we can read its timestamp.
    const response = await interaction.reply({ content: 'Pinging...', withResponse: true });
    const roundtrip = response.resource.message.createdTimestamp - interaction.createdTimestamp;

    // The gateway heartbeat is -1 until the first heartbeat after startup.
    const heartbeat = interaction.client.ws.ping;
    const heartbeatText = heartbeat >= 0 ? `${Math.round(heartbeat)} ms` : 'not measured yet';

    await interaction.editReply(`Pong. Roundtrip: ${roundtrip} ms. Gateway heartbeat: ${heartbeatText}.`);
  },
};

The command measures two different things:

  • Roundtrip is the time from the member sending the command to Discord creating the bot's reply. withResponse: true makes reply() return the created message, so no extra request is needed to read its timestamp.
  • Gateway heartbeat is the latency of the live connection discord.js keeps open. Right after startup it reads -1 until the first heartbeat, which is why the command handles that case.

/poll: a command with options

commands/utility/poll.js
const { MessageFlags, SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('poll')
    .setDescription('Start a Discord poll')
    .addStringOption((option) =>
      option.setName('question').setDescription('What do you want to ask?').setRequired(true).setMaxLength(300),
    )
    .addStringOption((option) =>
      option.setName('answers').setDescription('2 to 10 answers, separated by commas').setRequired(true),
    )
    .addIntegerOption((option) =>
      option.setName('hours').setDescription('How long the poll stays open (default 24)').setMinValue(1).setMaxValue(768),
    )
    .addBooleanOption((option) =>
      option.setName('multiple').setDescription('Let people pick more than one answer'),
    ),

  async execute(interaction) {
    const question = interaction.options.getString('question', true);
    const answers = interaction.options
      .getString('answers', true)
      .split(',')
      .map((answer) => answer.trim())
      .filter((answer) => answer.length > 0);
    const hours = interaction.options.getInteger('hours') ?? 24;
    const multiple = interaction.options.getBoolean('multiple') ?? false;

    // Discord allows up to 10 answers of up to 55 characters each. One answer isn't a poll.
    let problem = null;
    if (answers.length < 2) problem = 'Give at least 2 answers, separated by commas.';
    else if (answers.length > 10) problem = 'A poll can have at most 10 answers.';
    else if (answers.some((answer) => answer.length > 55)) problem = 'Each answer can be at most 55 characters.';

    if (problem) {
      await interaction.reply({ content: problem, flags: MessageFlags.Ephemeral });
      return;
    }

    await interaction.reply({
      poll: {
        question: { text: question },
        answers: answers.map((text) => ({ text })),
        duration: hours,
        allowMultiselect: multiple,
      },
    });
  },
};

Options are the typed inputs of a slash command. Discord validates what it can before your code runs: question is capped at 300 characters, hours must be a whole number from 1 to 768, and multiple can only be true or false. The limits match Discord's poll rules: a poll has at most 10 answers of 55 characters each and stays open for up to 32 days, which is 768 hours.

Some rules can't be expressed as option settings, such as "between 2 and 10 answers" (Discord sets the maximum; the minimum is this command's own rule), so the code checks them and replies privately. MessageFlags.Ephemeral makes a reply visible only to the member who ran the command. It replaced the old ephemeral: true option, which discord.js has marked deprecated since 14.17.

Commas split the answers because a slash command can't take a variable number of options. The alternative, ten optional answer1 to answer10 options, is clumsier to type.

/npm: deferReply for slow work

Discord waits 3 seconds for a bot to acknowledge a command. After that, the member sees "The application did not respond" and the reply is refused. Anything that calls another service, a database or a game server can take longer than that, so you acknowledge first and answer later.

commands/utility/npm.js
const { EmbedBuilder, SlashCommandBuilder } = require('discord.js');

// Fetches JSON with a 10-second limit. Returns null when the package doesn't exist.
async function getJson(url) {
  const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`${url} answered ${response.status}`);
  return response.json();
}

module.exports = {
  data: new SlashCommandBuilder()
    .setName('npm')
    .setDescription('Look up a package on the npm registry')
    .addStringOption((option) =>
      option.setName('package').setDescription('Package name, for example discord.js').setRequired(true).setMaxLength(214),
    ),

  async execute(interaction) {
    const name = interaction.options.getString('package', true).trim().toLowerCase();

    // Two HTTP requests can take longer than the 3 seconds Discord waits,
    // so acknowledge the command first. Members see "is thinking..." meanwhile.
    await interaction.deferReply();

    const encoded = encodeURIComponent(name);
    const [info, downloads] = await Promise.all([
      getJson(`https://registry.npmjs.org/${encoded}/latest`),
      // Download counts are a nice extra, so a failure there shouldn't sink the command.
      getJson(`https://api.npmjs.org/downloads/point/last-week/${encoded}`).catch(() => null),
    ]);

    if (!info) {
      // allowedMentions stops a name like "@everyone" from pinging anyone.
      await interaction.editReply({
        content: `There is no package called "${name}" on npm.`,
        allowedMentions: { parse: [] },
      });
      return;
    }

    const embed = new EmbedBuilder()
      .setTitle(`${info.name} ${info.version}`)
      .setURL(`https://www.npmjs.com/package/${info.name}`)
      .setDescription(info.description ? info.description.slice(0, 300) : 'No description.')
      .addFields(
        { name: 'License', value: typeof info.license === 'string' ? info.license : 'Not stated', inline: true },
        {
          name: 'Downloads last week',
          value: downloads ? downloads.downloads.toLocaleString('en-US') : 'Unknown',
          inline: true,
        },
      );

    await interaction.editReply({ embeds: [embed] });
  },
};

deferReply() is the acknowledgment. Members see "bot name is thinking..." and you have 15 minutes to replace it with editReply(). Call it before any slow work, and only once: an interaction gets one initial response.

A few details that matter in real bots:

  • Every outside request has a timeout. AbortSignal.timeout(10_000) gives up after 10 seconds instead of leaving the member waiting until the 15-minute window closes.
  • Not found is an answer, not an error. A 404 becomes a friendly reply; other failures throw and reach the error handler in index.js.
  • Optional data doesn't sink the command. If the download counter is down, the embed still arrives with "Unknown".
  • Member input is never allowed to ping. Without allowedMentions: { parse: [] }, someone could look up a "package" named after a user mention and have the bot ping that person.

The same pattern works for anything slow: an order lookup in your shop's API, a stats query, a game server status check.

Step 8: Register the commands with Discord

Discord only shows commands that you register through its API. Registering is separate from running the bot: you do it once, and again whenever you change a command's name, description or options. Changing what execute does needs no redeploy.

deploy-commands.js
const { REST, Routes } = require('discord.js');
const { loadCommands } = require('./load-commands');

const { DISCORD_TOKEN, CLIENT_ID, GUILD_ID } = process.env;
const isGlobal = process.argv.includes('--global');

if (!DISCORD_TOKEN || !CLIENT_ID || (!isGlobal && !GUILD_ID)) {
  throw new Error('Set DISCORD_TOKEN, CLIENT_ID and GUILD_ID in .env first.');
}

const body = loadCommands().map((command) => command.data.toJSON());

// A guild route updates one server instantly: use it while developing.
// The global route publishes to every server the bot is in.
const route = isGlobal
  ? Routes.applicationCommands(CLIENT_ID)
  : Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID);

// PUT replaces the whole list, so commands you deleted locally disappear from Discord too.
new REST()
  .setToken(DISCORD_TOKEN)
  .put(route, { body })
  .then((registered) => {
    const where = isGlobal ? 'globally' : `in server ${GUILD_ID}`;
    console.log(`Registered ${registered.length} command(s) ${where}: ${registered.map((c) => `/${c.name}`).join(', ')}`);
  })
  .catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });

Run it:

terminal
npm run deploy

It prints Registered 3 command(s) in server <your server ID>: /npm, /ping, /poll.

The script registers guild commands by default, and that is the right choice while you build:

Guild commands (npm run deploy) Global commands (npm run deploy:global)
Where they appear Only in the server set as GUILD_ID Every server that has your bot, and DMs with members who share a server with it
When changes show Instantly If someone uses a command before the update reaches their client, Discord rejects that attempt and reloads the command
Limit 100 slash commands per server 100 slash commands
Use for Development, and bots that live in one server Public bots in many servers

Step 9: Handle interactions and errors

index.js starts the bot, loads the commands and routes each slash command to its file:

index.js
const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
const { loadCommands } = require('./load-commands');

const token = process.env.DISCORD_TOKEN;
if (!token) throw new Error('DISCORD_TOKEN is missing. Start the bot with "npm start" so .env is loaded.');

// Guilds is the only intent slash commands need. Add others only when a feature needs their events.
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.commands = new Collection();
for (const command of loadCommands()) {
  client.commands.set(command.data.name, command);
}

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag} in ${readyClient.guilds.cache.size} server(s)`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

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

  try {
    if (!command) {
      // Happens when a command is still registered with Discord but its file is gone.
      await interaction.reply({ content: 'That command no longer exists.', flags: MessageFlags.Ephemeral });
      return;
    }
    await command.execute(interaction);
  } catch (error) {
    console.error(`/${interaction.commandName} failed:`, error);
    const content = 'Something went wrong while running that command.';
    try {
      if (interaction.deferred && !interaction.replied) {
        // Replace the "is thinking..." placeholder instead of leaving it hanging.
        await interaction.editReply({ content });
      } else if (interaction.replied) {
        await interaction.followUp({ content, flags: MessageFlags.Ephemeral });
      } else {
        await interaction.reply({ content, flags: MessageFlags.Ephemeral });
      }
    } catch (replyError) {
      console.error('Could not tell the member about the error:', replyError);
    }
  }
});

// Without an error listener, an 'error' event would crash the process.
client.on(Events.Error, (error) => console.error('Client error:', error));

client.login(token);

How it fits together:

  • One listener handles every command. interactionCreate fires for every interaction, including buttons and modals. isChatInputCommand() keeps this handler to slash commands, and the command name picks the file.
  • clientReady, not ready. discord.js renamed the event in 14.22 and prints a deprecation warning for ready, which older tutorials still use.
  • The error handler matches the command's state. If the command had deferred, the "is thinking..." message is replaced; if it had already replied, a private follow-up is sent; otherwise the member gets a private reply.
  • The error listener is the safety net. discord.js re-emits errors thrown inside async listeners as an error event on the client, and with no listener for it Node treats that as fatal and the whole bot exits. I confirmed both behaviors while testing.

Step 10: Run it

terminal
npm run deploy
npm start

The second command prints Logged in as followed by your bot's name and the number of servers it's in. Then, in your test server:

  1. Type /ping. The reply shows two latencies. If the heartbeat says "not measured yet", try again in a minute.
  2. Run /poll question:Map for Friday? answers:Mirage, Inferno, Nuke. A native poll appears that closes after 24 hours.
  3. Run /poll with only one answer. You get a private message explaining the rule, and nothing is posted.
  4. Run /npm package:discord.js. You see "is thinking..." for a moment, then an embed with the latest version and last week's downloads.

While you develop, use npm run dev instead of npm start, so the bot restarts every time you save. Remember that editing a command's options also needs npm run deploy.

Keep it online

The bot only works while npm start is running, so it goes offline when you close the terminal or your computer sleeps. To keep it up around the clock, run it on a server:

  • A managed bot host runs it for you. PebbleHost, for example, lists a 1 GB bot plan that runs Node.js at $3.00 a month (as of September 2026), which is plenty for a bot like this.
  • A VPS gives you full control. Run the bot under a process manager such as systemd, pm2 or Docker with a restart policy, so it comes back after a crash or a reboot.

Either way, the .env file goes on the server, never into Git. My guide to hosting a Discord bot 24/7 compares the free and paid options in detail. If you'd rather not look after a server at all, hosting and Care plans start at $49 a month and include monitoring and library updates.

Going global

When the bot works in your test server and should run in others, register the commands globally:

terminal
npm run deploy:global

Your test server now shows every command twice, once from the guild list and once from the global list. Clear the guild copies with this one-liner, run in the project folder:

terminal
node --env-file=.env -e "const { REST, Routes } = require('discord.js'); new REST().setToken(process.env.DISCORD_TOKEN).put(Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID), { body: [] }).then(() => console.log('Guild commands cleared.'));"

The discord.js guide suggests a cleaner long-term setup: a second application with its own token for development, so your test commands never mix with the live ones.

Two limits to know. Discord allows 200 command creations per day per server, which only bites when a script deletes and recreates commands on every start; re-sending commands that already exist, as this deploy script does, doesn't count as creating them. And a bot in 2,500 or more servers has to use sharding.

Troubleshooting

What you see Likely cause and fix
DISCORD_TOKEN is missing You ran node index.js directly, which skips .env. Use npm start.
An invalid token was provided. The token in .env is wrong or was reset. Reset it on the Bot page and paste the new one.
Error: Used disallowed intents The code requests a privileged intent that is off on the Bot page. Turn it on, or remove it from intents.
Commands don't appear when you type / You haven't run npm run deploy, GUILD_ID is a different server, or Discord needs a client restart (Ctrl+R on desktop).
DiscordAPIError[50001]: Missing Access from the deploy script The bot isn't in the server set as GUILD_ID. Invite it with the link from step 4.
"The application did not respond" The bot isn't running, or a command took more than 3 seconds without deferReply(). Check the terminal for an error.
DiscordAPIError[10062]: Unknown interaction The first response came after the 3-second window, usually slow work before deferReply(). Defer first.
The reply to this interaction has already been sent or deferred. The code called reply() or deferReply() twice. Use editReply() or followUp() after the first response.
Every command shows twice Commands are registered both in your server and globally. Clear the guild copies as shown above.
DeprecationWarning: The ready event has been renamed to clientReady, or Warning: Supplying "ephemeral" or "fetchReply" Code copied from an older tutorial. Use Events.ClientReady, flags: MessageFlags.Ephemeral and withResponse: true.

If the bot used to work and stopped, my guide to fixing a Discord bot that isn't working goes through the causes one at a time.

How I tested this code

I can't log in to Discord from a test script without a real bot token, so I ran the published files unchanged against a fake gateway. The harness replaces client.login and every Discord HTTP request with a recorder, then feeds interaction payloads shaped like Discord's into discord.js's own interaction handling, so the builders, option parsing, poll payloads and response methods are the real ones.

With a fresh install of discord.js 14.27.0 on Node.js 24.15.0, these cases passed:

Case What was checked
npm run deploy and deploy:global One PUT with /npm, /ping and /poll to the guild route, or the global route without GUILD_ID; option types, lengths and ranges as documented
npm start and the loader Only the Guilds intent; three commands loaded; ready message printed; a missing export or a duplicate command name stops with a clear error
/ping Reply requested with the response; roundtrip and heartbeat text, including before the first heartbeat
/poll Trimmed answers, 24-hour default, custom hours and multiple choice; private errors for 1 answer, 11 answers and a 56-character answer
/npm Deferred first, then an embed; unknown package answered without pings; downloads API down still gives an embed; registry down replaces "is thinking..." with the error message; live lookups against the real npm registry
Error handler Private reply when nothing was sent yet, private follow-up after a reply, reply for a registered but deleted command
Going global one-liner Sends an empty PUT to the guild commands route
error listener (separate script) An async listener that throws: with the listener the bot logs it and keeps running; without it, Node exits

No deprecation warnings were printed. What this can't prove is how Discord's servers respond to your particular server and permissions, which is what the troubleshooting table is for.

Where to go next

You now have the structure most discord.js bots use: one file per command, a loader, a deploy script and one interaction handler. From here:

  • Buttons, select menus and modals use the same interactionCreate listener. My ticket bot tutorial builds a complete ticket system with them.
  • More about slash commands, including subcommands, autocomplete and who can see a command, is in my guide to Discord slash commands.
  • A database is the next step once the bot needs to remember anything between restarts, such as reminders, levels or settings per server.

If your bot needs more than an afternoon project, for example payments, a league's roster or your own API, I build custom Discord bots on your own application and hand over the code. And if you already have a bot that has stopped working, Bot Rescue gets it running again for a fixed $149, and you pay nothing if I can't fix it.

Questions

Is creating a Discord bot free?

Yes. Creating an application in the Discord Developer Portal costs nothing, and discord.js is open source under the Apache-2.0 license. The only cost is a host for when your computer is off, and small bots run on plans from about $3 a month, as how to host a Discord bot 24/7 explains.

Do I need the Message Content intent for slash commands?

No. A slash command's name and options arrive inside the interaction, not as message text, so this bot runs with the Guilds intent only. Message Content matters when the bot has to read other people's ordinary messages, for example for keyword filters, old-style prefix commands or ticket transcripts.

How many slash commands can a Discord bot have?

Discord allows 100 global slash commands per app, plus up to 100 more registered in each server. There is also a limit of 200 command creations per day per server, which only matters if a script deletes and recreates commands on every start. Subcommands live inside their parent command and don't count separately, so a bot with many actions groups them, like /ticket open and /ticket close.

Is making a Discord bot hard?

Not if you know basic JavaScript: the bot in this guide is about 240 lines, and several steps are Developer Portal settings rather than code. The harder parts come later: storing data, permissions in servers you don't control, rate limits and keeping the bot online. That is usually the point where people hire someone to build it.

Can I make a Discord bot on my phone?

Not with discord.js. The code has to run on a machine with Node.js installed: your computer while you build it, then a server. If a phone is all you have, a no-code builder is the realistic route; Discord bot makers compared covers the options and their limits.

Can I build the same bot in Python or TypeScript?

Yes. discord.js ships its own TypeScript definitions, so you can write the same bot in TypeScript and compile it to JavaScript, and discord.py covers the same slash commands, options and deferred replies in Python. discord.js vs discord.py compares the two with one bot written in both.

Sources

Prices and features were checked on September 27, 2026.

  1. Discord Developer Docs, Building your first Discord app (app creation, token, install settings)
  2. Discord Developer Docs, Gateway: intents, privileged intents and the Message Content intent
  3. Discord Developer Docs, Getting started with Privileged Intent Review (10,000-user threshold, June 2026)
  4. Discord Developer Docs, Application commands (guild vs global, limits, option lengths)
  5. Discord Developer Docs, Receiving and responding to interactions (3 seconds, 15 minutes)
  6. Discord Developer Docs, Poll resource (question, answer and duration limits)
  7. Discord Developer Docs, OAuth2 scopes (applications.commands included with bot)
  8. Discord Developer Docs, Permissions (Send Polls and other bit values)
  9. Discord Developer Docs, Application resource (bot_public, install links)
  10. Discord API docs issue #5292, making a bot private while an install link is set
  11. Discord Support, Where can I find my User/Server/Message ID?
  12. discord.js Guide, Registering commands
  13. discord.js Guide, Command responses (deferReply, withResponse)
  14. discord.js on npm (14.27.0, requires Node.js 18 or newer)
  15. discord.js releases (ephemeral option deprecated in 14.17.0, clientReady rename in 14.22.0, 14.27.0 on July 15, 2026)
  16. Node.js release schedule (end-of-life dates)
  17. Node.js command-line options (--env-file, --watch)
  18. npm, download counts API (used by /npm)
  19. PebbleHost, Discord bot hosting ($3.00 a month, 1 GB; checked September 2026)

Tell me what your server needs

Send a short brief. You get a reply within one business day with questions or a price range, and a fixed quote before any work starts.