What you'll build
By the end of this tutorial you'll have a working Discord bot written in TypeScript with discord.js v14, the standard library for building Discord bots in Node. It does two things that cover the two halves of every bot: it answers a /ping slash command (a command a user runs), and it posts a welcome message when someone joins your server (an event the bot reacts to). Learn those two shapes and you can build almost anything else on Discord.
Every file you need is in this page: a package.json, a tsconfig.json, a deploy-commands.ts script that registers the slash command, and a src/index.ts that runs the bot. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone, no hidden files.
discord.js or discord.py? The honest verdict
Both are real, well-maintained libraries, and neither is wrong. The choice comes down to which language you already work in.
Pick discord.js (this tutorial) if you already write JavaScript or TypeScript, or you want first-class TypeScript typings and the built-in slash-command builder. discord.js ships its own types, so your editor autocompletes intents, event names, and interaction methods, and the compiler catches a whole class of mistakes before the bot ever connects.
Pick discord.py if you are starting from zero and already learning Python, or you are following our free Discord bot path, which builds the same kind of bot on two free Python courses. Same concepts, different language.
Here is the one gotcha that trips people up in either language, so learn it now. Discord has a setting called the Message Content Intent. It controls whether your bot can read the text of normal messages. It is a *privileged* intent, which means Discord gates it: once your bot is in 75 or more servers, you have to apply for verification to keep it. Older tutorials teach "prefix commands" like !ping, which read message text and therefore need that intent. That is the slow road that hits a wall at scale.
The modern answer, and what this tutorial builds, is slash commands. A slash command like /ping is registered with Discord ahead of time and delivered to your bot as a structured interaction, so you never read raw message text and never touch the Message Content Intent. This is why slash commands are the default in 2026 and why we build them from the start.
What you need
- Node.js 20.6 or newer. discord.js v14 needs Node 18+, and we use Node's built-in
--env-fileflag (added in 20.6) to load your bot token without an extra package. Check withnode --version. - A Discord account and a server you manage. Create a throwaway server for testing: click the
+in Discord, choose *Create My Own*, and you have an admin server to invite the bot into. - A registered Discord application. You make this at the Discord Developer Portal. Steps are below.
Register the bot and grab your token
This part happens in the browser, once, before any code. It is free: you do not need Discord Nitro or any paid plan to create a bot.
- Go to the Discord Developer Portal and click New Application. Give it a name. On the General Information page, copy the Application ID: that is your client ID.
- Open the Bot tab. Under Privileged Gateway Intents, turn on Server Members Intent. The welcome listener needs it (it is how your bot is told someone joined), and it is privileged for the same reason Message Content is. Leave Message Content off: slash commands do not need it.
- Still on the Bot tab, click Reset Token, then copy the token. Treat it like a password: anyone with it controls your bot. Never commit it or paste it into a public place.
- Open the OAuth2 > URL Generator, tick the
botandapplications.commandsscopes, then under bot permissions tick Send Messages. Copy the generated URL, open it in your browser, and invite the bot to your test server.
You now have three values: the bot token, the Application ID (client ID), and your test server ID (right-click the server icon with Developer Mode on, then Copy Server ID). Keep them handy for the .env file below.
Set up the project
Create a fresh project and install the one runtime dependency plus the TypeScript tooling:
mkdir discord-bot-ts
cd discord-bot-ts
npm init -y
npm install discord.js
npm install -D typescript tsx @types/nodediscord.js is the only thing that ships to production. tsx runs TypeScript directly so there is no build step while you follow along, and @types/node gives you typed process.env. Open package.json, add "type": "module", and add these scripts:
{
"type": "module",
"scripts": {
"deploy": "tsx --env-file=.env deploy-commands.ts",
"start": "tsx --env-file=.env src/index.ts",
"build": "tsc"
}
}The --env-file=.env flag is Node loading your secrets from a file, no dotenv package required. Create a .env file in the project root with the three values from the last step:
DISCORD_TOKEN=paste-your-bot-token-here
DISCORD_CLIENT_ID=paste-your-application-id-here
DISCORD_GUILD_ID=paste-your-test-server-id-hereAdd .env and node_modules to a .gitignore right now, before you write any code, so the token never lands in git:
echo ".env" >> .gitignore
echo "node_modules" >> .gitignoreLast, add a tsconfig.json at the project root. This runs cleanly with tsx in development and compiles with tsc for a production build:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"types": ["node"]
},
"include": ["src", "deploy-commands.ts"]
}Define and register the /ping slash command
Slash commands are not something your running bot invents on the fly. You register them with Discord's REST API first, and Discord then shows them in the slash-command menu and delivers them to your bot as interactions. Registering per server (a "guild") is instant, which is what you want while developing; global commands can take up to an hour to appear.
Create deploy-commands.ts in the project root. It uses discord.js's SlashCommandBuilder to describe the command and its REST and Routes helpers to send it to Discord:
import { REST, Routes, SlashCommandBuilder } from 'discord.js';
// Describe every slash command here. Add more builders to the array as your
// bot grows; re-run this script whenever you change them.
const commands = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong and the current gateway latency.'),
].map((command) => command.toJSON());
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !clientId || !guildId) {
throw new Error(
'Set DISCORD_TOKEN, DISCORD_CLIENT_ID, and DISCORD_GUILD_ID in your .env file.',
);
}
const rest = new REST({ version: '10' }).setToken(token);
async function main(): Promise<void> {
console.log(`Registering ${commands.length} slash command(s)...`);
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
body: commands,
});
console.log('Done. /ping is registered on your test server.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Two things worth noting. The guard that throws when a value is missing is not busywork: a bot that boots with an undefined token fails with a confusing error deep inside discord.js, so catching it here with a message that names the fix saves real time. And because token, clientId, and guildId are const, TypeScript keeps the "definitely a string" narrowing from that guard all the way into main, so the compiler is happy without a single ! or cast.
Run it once. You only need to re-run it when you add or change a command, not every time the bot restarts:
npm run deploy
# -> Registering 1 slash command(s)...
# -> Done. /ping is registered on your test server.Solid on JavaScript first?
discord.js is Node under the hood, so the stronger your JavaScript, the easier the bot. The Odin Project's Full Stack JavaScript path covers async JS, modules, and Node for free, and freeCodeCamp's JavaScript Algorithms and Data Structures is a good structured alternative. We line both up on our JavaScript and TypeScript courses page.
Build the bot
Now the bot itself. Create src/index.ts. This one file logs in, answers /ping, and greets new members. Paste it in, then read the walk-through underneath:
import {
Client,
Events,
GatewayIntentBits,
type Interaction,
type GuildMember,
} from 'discord.js';
const token = process.env.DISCORD_TOKEN;
if (!token) {
throw new Error('Set DISCORD_TOKEN in your .env file before starting the bot.');
}
// Intents tell Discord which events to send. Ask only for what you use:
// Guilds for basic server data, GuildMembers for join events (privileged,
// so it must be enabled on the Bot tab of the Developer Portal).
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
// A user ran a slash command.
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'ping') {
const latency = Math.round(client.ws.ping);
await interaction.reply(`Pong! Gateway latency is ${latency}ms.`);
}
});
// Someone joined the server.
client.on(Events.GuildMemberAdd, async (member: GuildMember) => {
const channel = member.guild.systemChannel;
if (!channel) return; // no default channel set for this server
await channel.send(`Welcome, ${member}! Glad you made it.`);
});
client.login(token);The shape to take away: a bot is an event loop. You create a Client, tell it which GatewayIntentBits you need, and attach handlers.
`interaction.isChatInputCommand()` is a type guard. After that if, TypeScript knows interaction is a slash-command interaction and lets you read commandName and call reply. That is the payoff of the built-in typings: the editor guides you to the right methods instead of guessing.
`member.guild.systemChannel` is the server's default channel, and it can be null if the owner turned it off, so the if (!channel) return guard is doing real work, not defensive noise. `${member}` inside the message renders as a proper @mention because discord.js knows how to serialize a member into Discord's mention format.
Run and test it
Start the bot:
npm start
# -> Logged in as your-bot-name#1234Now try both paths in your test server. Type /ping in any channel: the bot should reply almost instantly with the gateway latency. To test the welcome listener, the simplest way is to invite a second account (or ask a friend) to join, or leave and rejoin yourself: the bot posts the welcome message in the server's default channel.
If /ping does not show up in the slash-command menu, you either skipped npm run deploy or registered against a different server ID than the one you are testing in. If the welcome message never fires, check that Server Members Intent is enabled on the Bot tab; that is the single most common cause.
Keep leveling up your backend
A bot is a long-running Node service, which is backend work. Full Stack Open (University of Helsinki) has a free, project-based Node and Express track, and freeCodeCamp's Back End Development and APIs pairs well with it. Both sit on our backend developer roadmap.
Keep it online for free
Here is the honest limitation. npm start in your terminal only runs the bot while that terminal is open and your laptop is awake. A real bot has to stay connected around the clock, which means hosting it somewhere always on.
The good news is you can do that for free to start. Free options include Railway's starter tier, Fly.io's free allowance, and Oracle Cloud's always-free small VPS. These are the same hosts our free Discord bot path points to. The deploy flow is roughly: push your code to GitHub (with .env still ignored), set the same environment variables in the host's dashboard, and run npm run build then node dist/index.js (or tsx src/index.ts) as the start command. Avoid free tiers that sleep on idle, like Replit's free plan, because a sleeping process misses events. A small always-on VPS or Railway is more reliable for a bot that needs to be online.
Keep a reference handy
You now have a bot in two small files: deploy-commands.ts to register commands and src/index.ts to run them. Adding a second command is the same two steps you already did: add another SlashCommandBuilder to the array in deploy-commands.ts, re-run npm run deploy, then add another if (interaction.commandName === '...') branch in the interaction handler.
Want a quick-reference card for the TypeScript syntax you'll lean on while building bots (types, async/await, modules)? Our TypeScript cheat sheet is worth keeping open while you build.
Where to go next
You have the two core shapes: a command and an event listener. Everything else is a variation.
Add command options and subcommands: SlashCommandBuilder has addStringOption, addUserOption, and more, so a /kick @user reason command reads its arguments as typed values instead of parsing text.
Give the bot memory: if you want the bot to remember anything between restarts (warnings, points, settings), you need a database. Our tutorial on building a REST API with Express and TypeScript covers the same TypeScript and Node patterns you'd use to add SQLite or Postgres to a bot.
Prefer Python? Our free Discord bot path builds the same kind of bot with discord.py on two free Python courses. Same ideas, different language.
Frequently asked questions
What is the difference between discord.js and discord.py?
They are the same idea in two languages. discord.js is the JavaScript/TypeScript library and is what this tutorial uses; discord.py is the Python option, covered by our free Discord bot path at /build/discord-bot. Pick discord.js if you already work in JS or TS or want first-class TypeScript typings; pick discord.py if you are learning Python. The concepts (intents, slash commands, event listeners) are the same either way.
Do I need Discord Nitro or a paid plan to build a bot?
No. Creating and developing a bot is completely free. You register an application at the Discord Developer Portal, add a bot user, copy the token, and invite it to a server you manage. Nitro is a cosmetic subscription for users and has nothing to do with building bots. The only thing that can cost money later is hosting the bot 24/7, and even that has free tiers to start.
What is the difference between slash commands and prefix commands, and why does it matter?
Prefix commands are the old style, like typing !ping in a channel, and they require your bot to read the text of every message. That means turning on the Message Content Intent, which Discord treats as privileged and gates behind verification once your bot is in 75 or more servers. Slash commands like /ping are registered with Discord ahead of time and delivered as structured interactions, so your bot never reads raw message text and never needs that intent. That is why slash commands are the modern default and what this tutorial builds.
Why do I have to run a separate deploy-commands script?
Slash commands live on Discord's side, not in your running bot. You register them once with the REST API so Discord knows to show them in the command menu and route them to your bot. Registering per server (guild) is instant, which is ideal while developing. You only re-run the deploy script when you add or change a command, not on every bot restart.
How do I add more commands?
Two steps. First, add another SlashCommandBuilder to the array in deploy-commands.ts and re-run npm run deploy. Second, add another branch in the interactionCreate handler in src/index.ts that checks interaction.commandName and replies. As your command list grows, most projects move each command into its own file and load them from a folder, but the two-step pattern stays the same.
Does a Discord bot need a database?
Only if it has to remember things between restarts, like user warnings, points, or per-server settings. The bot in this tutorial is stateless, so it needs nothing. When you do need persistence, SQLite is the easiest start and Postgres scales further. Our REST API tutorial at /tutorials/build-rest-api-express-typescript walks through the same TypeScript and Node data patterns you would use.
Where can I host a Discord bot for free?
A bot has to stay running to respond, so you host it somewhere always on. Free options include Railway's starter tier, Fly.io's free allowance, and Oracle Cloud's always-free small VPS. Avoid free tiers that sleep on idle (like Replit's free plan), because a sleeping process misses events. For a bot that needs to be online around the clock, a small always-on VPS or Railway is more reliable.
The bot logs in but /ping does not appear. What is wrong?
Almost always one of two things. Either you did not run npm run deploy, so the command was never registered, or you registered it against a different DISCORD_GUILD_ID than the server you are testing in. Confirm the deploy script printed its success line, and double-check that the guild ID in your .env matches the test server. Guild commands appear instantly, so there is no waiting involved.