Every Discord bot tutorial on the internet uses Node.js. I get it. Node has been the default for a decade, and Discord.js was built to run on it. But in 2026, there is a better option.
Bun runs TypeScript natively without a build step. It loads .env files automatically with no dotenv package needed. It cold-starts in under 15ms, installs packages in seconds, and ships with a built-in test runner and bundler. If you are building a Discord bot today and you reach for Node.js by default, you are choosing a slower development experience for no reason.
This guide builds a Discord bot from scratch using Bun, TypeScript, and discord.js v14. By the end you will have a bot with slash commands, embed responses, event handling, and a deployment setup that actually makes sense. Every line of code is here, and it all runs on Bun.
What You Need Before Starting
- Bun installed. If you have not yet:
curl -fsSL https://bun.sh/install | bash - A Discord account with a server where you have admin privileges
- Basic TypeScript familiarity. You do not need to be an expert, but you should know what a type annotation looks like
- A code editor. VS Code works well here
Step 1: Create Your Discord Application
Before writing any code, you need a bot account in Discord’s developer system.
Go to discord.com/developers/applications and click New Application. Give it a name, something like “my-bun-bot” for now. Click Create.
In the left sidebar, click Bot. You will see a section for the bot token. Click Reset Token, confirm the action, and copy what it gives you. Store it somewhere safe immediately. You cannot see it again once you leave the page, and this token is the key to your bot’s account.
Under the Privileged Gateway Intents section on the same page, enable Message Content Intent. Your bot needs this to read message content in servers.
Now go to OAuth2 in the sidebar, then URL Generator. Under Scopes, check bot and applications.commands. Under Bot Permissions, check:
- Read Messages/View Channels
- Send Messages
- Embed Links
- Use Slash Commands
Copy the generated URL at the bottom of the page. Open it in your browser and add the bot to your test server.
You also need your Application ID. Go back to the General Information tab and copy it. You will need this alongside the token.
Step 2: Initialize the Project
Create the project directory and let Bun set it up:
mkdir discord-bot && cd discord-bot
bun init -y
bun add discord.js
bun add -d @types/bunThat is the entire dependency install. No ts-node, no nodemon, no dotenv. Bun handles all of that natively.
Create the folder structure:
discord-bot/
src/
index.ts
commands/
ping.ts
userinfo.ts
serverstats.ts
events/
ready.ts
interactionCreate.ts
utils/
deploy-commands.ts
types.d.ts
.env
.gitignore
package.json
tsconfig.jsonCreate your .gitignore right now before you forget:
.env
node_modules/
dist/Add this tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}The bun-types entry gives you type definitions for Bun-specific APIs like import.meta.dir and Bun.env. The moduleResolution: "bundler" setting is the right option for projects that use a bundler or runtime like Bun instead of standard Node module resolution.
Step 3: Set Up Environment Variables
Create your .env file:
DISCORD_TOKEN=your_bot_token_here
CLIENT_ID=your_application_id_here
GUILD_ID=your_test_server_id_hereThe GUILD_ID is the ID of your test server. Right-click on your server name in Discord and click “Copy Server ID” (you need Developer Mode enabled in Discord settings for this option to appear).
Here is the part where Bun saves you from yourself: you do not need dotenv. Bun reads .env files automatically when you run any script. Access your variables anywhere with:
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.CLIENT_ID;
const guildId = process.env.GUILD_ID;No require('dotenv').config(). No setup. It just works.
Step 4: Extend the Discord.js Client Type
Discord.js ships a Client class. We are going to attach a commands collection to it. TypeScript needs to know about this addition, so create src/types.d.ts:
import { Collection, SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
interface Command {
data: SlashCommandBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
declare module 'discord.js' {
interface Client {
commands: Collection<string, Command>;
}
}This declaration merges with discord.js’s own types. Anywhere you access client.commands in TypeScript, the compiler now knows what shape it has.
Step 5: Create the Bot Entry Point
src/index.ts is where everything starts:
import { Client, GatewayIntentBits, Collection } from 'discord.js';
import { readdirSync } from 'fs';
import { join } from 'path';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.commands = new Collection();
// Load commands dynamically from the commands directory
const commandsPath = join(import.meta.dir, 'commands');
const commandFiles = readdirSync(commandsPath).filter(f => f.endsWith('.ts'));
for (const file of commandFiles) {
const command = await import(join(commandsPath, file));
client.commands.set(command.data.name, command);
}
// Load events dynamically from the events directory
const eventsPath = join(import.meta.dir, 'events');
const eventFiles = readdirSync(eventsPath).filter(f => f.endsWith('.ts'));
for (const file of eventFiles) {
const event = await import(join(eventsPath, file));
if (event.once) {
client.once(event.name, (...args: unknown[]) => event.execute(...args));
} else {
client.on(event.name, (...args: unknown[]) => event.execute(...args));
}
}
client.login(process.env.DISCORD_TOKEN);Two things worth noting here. First, import.meta.dir is Bun’s equivalent of Node’s __dirname. It gives you the directory of the current file. Second, the await import() calls at the top level work because Bun supports top-level await natively, the way the ESNext spec defines it.
The dynamic loading pattern means you never have to register new commands manually. Drop a .ts file in the commands folder, and it loads automatically on next start.
Step 6: Add Event Handlers
Create src/events/ready.ts:
import { Events, Client } from 'discord.js';
export const name = Events.ClientReady;
export const once = true;
export function execute(client: Client<true>) {
console.log(`Online as ${client.user.tag}`);
console.log(`Serving ${client.guilds.cache.size} server(s)`);
}The once: true export tells our loader to use client.once instead of client.on, so this handler fires exactly once when the bot connects.
Create src/events/interactionCreate.ts:
import { Events, Interaction } from 'discord.js';
export const name = Events.InteractionCreate;
export const once = false;
export async function execute(interaction: Interaction) {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command found for: ${interaction.commandName}`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error executing ${interaction.commandName}:`, error);
const errorMessage = { content: 'Something went wrong running that command.', ephemeral: true };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(errorMessage);
} else {
await interaction.reply(errorMessage);
}
}
}The error handling here covers the cases where an interaction has already been replied to or deferred. If you skip that check and try to call reply() on an already-replied interaction, Discord throws and your bot process crashes.
Step 7: Build Your First Slash Commands
Create src/commands/ping.ts:
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('ping')
.setDescription('Check how fast the bot is responding');
export async function execute(interaction: ChatInputCommandInteraction) {
const sent = await interaction.reply({ content: 'Measuring...', fetchReply: true });
const roundtrip = sent.createdTimestamp - interaction.createdTimestamp;
const ws = Math.round(interaction.client.ws.ping);
await interaction.editReply(`Pong. Round-trip: **${roundtrip}ms** | WebSocket: **${ws}ms**`);
}Create src/commands/userinfo.ts:
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('userinfo')
.setDescription('Get information about a user')
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to look up (defaults to you)')
.setRequired(false)
);
export async function execute(interaction: ChatInputCommandInteraction) {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const member = await interaction.guild?.members.fetch(targetUser.id).catch(() => null);
const joinedAt = member?.joinedAt
? `<t:${Math.floor(member.joinedAt.getTime() / 1000)}:D>`
: 'Unknown';
const createdAt = `<t:${Math.floor(targetUser.createdTimestamp / 1000)}:D>`;
const roles = member?.roles.cache
.filter(r => r.name !== '@everyone')
.map(r => r.toString())
.join(', ') || 'None';
const embed = new EmbedBuilder()
.setAuthor({ name: targetUser.tag, iconURL: targetUser.displayAvatarURL() })
.setThumbnail(targetUser.displayAvatarURL({ size: 256 }))
.addFields(
{ name: 'User ID', value: targetUser.id, inline: true },
{ name: 'Account Created', value: createdAt, inline: true },
{ name: 'Joined Server', value: joinedAt, inline: true },
{ name: 'Roles', value: roles }
)
.setColor(0x5865F2)
.setTimestamp()
.setFooter({ text: `Requested by ${interaction.user.tag}` });
await interaction.reply({ embeds: [embed] });
}The <t:timestamp:D> syntax is Discord’s built-in timestamp formatting. It renders the date in the user’s own timezone automatically, which is a much better experience than hardcoding a date format.
Step 8: Add a More Useful Command
Create src/commands/serverstats.ts:
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('serverstats')
.setDescription('Show statistics for this server');
export async function execute(interaction: ChatInputCommandInteraction) {
const guild = interaction.guild;
if (!guild) {
await interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true });
return;
}
await guild.fetch();
const totalMembers = guild.memberCount;
const onlineMembers = guild.members.cache.filter(
m => m.presence?.status !== 'offline' && m.presence?.status !== undefined
).size;
const textChannels = guild.channels.cache.filter(c => c.type === 0).size;
const voiceChannels = guild.channels.cache.filter(c => c.type === 2).size;
const roles = guild.roles.cache.size - 1; // subtract @everyone
const boostLevel = guild.premiumTier;
const boostCount = guild.premiumSubscriptionCount ?? 0;
const createdAt = `<t:${Math.floor(guild.createdTimestamp / 1000)}:D>`;
const embed = new EmbedBuilder()
.setTitle(guild.name)
.setThumbnail(guild.iconURL({ size: 256 }) ?? null)
.addFields(
{ name: 'Members', value: `${totalMembers.toLocaleString()} total`, inline: true },
{ name: 'Channels', value: `${textChannels} text, ${voiceChannels} voice`, inline: true },
{ name: 'Roles', value: `${roles}`, inline: true },
{ name: 'Server Boosts', value: `Level ${boostLevel} (${boostCount} boosts)`, inline: true },
{ name: 'Created', value: createdAt, inline: true },
)
.setColor(0x57F287)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}Step 9: Register Slash Commands with Discord
Slash commands have to be registered with Discord’s API before they appear in the UI. Create src/utils/deploy-commands.ts:
import { REST, Routes } from 'discord.js';
import { readdirSync } from 'fs';
import { join } from 'path';
const commands: object[] = [];
const commandsPath = join(import.meta.dir, '..', 'commands');
const commandFiles = readdirSync(commandsPath).filter(f => f.endsWith('.ts'));
for (const file of commandFiles) {
const command = await import(join(commandsPath, file));
commands.push(command.data.toJSON());
}
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
console.log(`Registering ${commands.length} slash command(s)...`);
// Guild commands update instantly. Use these during development.
await rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID!, process.env.GUILD_ID!),
{ body: commands }
);
console.log('Done. Commands are live in your test server.');Run this once:
bun run src/utils/deploy-commands.tsGuild-scoped commands (the kind registered to a specific server ID) go live instantly. Global commands, registered with Routes.applicationCommands(clientId) instead, take up to an hour to propagate. Use guild commands during development, switch to global when you deploy.
Step 10: Add Scripts and Run
Update package.json:
{
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun run src/index.ts",
"deploy": "bun run src/utils/deploy-commands.ts"
}
}bun --watch restarts the process automatically when any file changes. No nodemon, no extra package, built in.
Start the bot:
bun run devYou should see your ready event log in the terminal within a second or two. Go to your test server and type / to see your slash commands listed. Run /ping, /userinfo, and /serverstats to verify everything works.
On my machine the bot is online in about 12ms after the Bun process starts. With a Node.js and ts-node setup doing the same thing, the same startup takes over a second. That difference matters more than it sounds because every time you restart during development, you wait. In a day of active development, those seconds add up to minutes.
Step 11: Deployment
When you are ready to run this somewhere permanently, you have two good options.
VPS with PM2
If you have a Linux VPS (DigitalOcean, Hetzner, Vultr), install Bun on it and use PM2 to keep the process running:
# On your VPS
git clone https://github.com/you/discord-bot.git
cd discord-bot
bun install
bun run deploy
# Install PM2 globally via bun
bun add -g pm2
# Start the bot under PM2
pm2 start src/index.ts --interpreter bun --name discord-bot
pm2 save
pm2 startupPM2 will restart the bot automatically if it crashes and will survive server reboots after you run pm2 startup.
Docker
If you prefer containers, here is a minimal Dockerfile:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
CMD ["bun", "run", "src/index.ts"]Build and run:
docker build -t discord-bot .
docker run -d --env-file .env --name discord-bot discord-botThe official oven/bun Docker image is well-maintained and small. The --frozen-lockfile flag ensures the install matches exactly what you tested locally.
Why Bun Instead of Node.js
To be concrete about what you are actually getting:
Native TypeScript. You write .ts files and run them directly. No ts-node, no @babel/register, no build step that produces a dist/ folder you have to remember to rebuild. The DX improvement is real and consistent.
No dotenv. Bun reads .env automatically. Remove one package from your dependencies, remove the setup call from your entry point, and stop wondering whether it loaded before the code that needs it ran.
Speed that actually matters in dev. The 12ms startup versus 1,000ms+ for ts-node is the kind of difference you notice when you are restarting the bot ten times during a debugging session.
bun --watch built in. Hot reloading with no extra packages and no configuration file.
Faster installs. bun install is between 6x and 20x faster than npm install in my experience. If you have a CI pipeline, this compounds quickly.
Common Mistakes to Avoid
Forgetting to enable Gateway Intents. If your bot can see messages in some servers but not others, check whether the server has Community features enabled with stricter intent requirements. Also check that you enabled Message Content Intent in the Developer Portal.
Deploying guild commands to the wrong guild. Your GUILD_ID has to match the server you added the bot to. Test server IDs and production server IDs are different.
Committing your .env file. Your bot token is a secret. Anyone who has it can log in as your bot, join any server it is in, and do whatever permissions allow. Confirm .env is in your .gitignore before your first commit.
Using global commands during development. They take up to an hour to update. You will waste a lot of time wondering why your command changes are not showing up. Always use guild commands during development.
Not handling deferred interactions. If your command does anything async that might take more than three seconds, use interaction.deferReply() first. Discord will show a loading indicator and give you fifteen minutes to respond instead of three seconds.
What to Build Next
Now that you have the scaffold, the extension points are straightforward.
Adding a database is the most natural next step for anything beyond toy commands. Prisma works with Bun and gives you a type-safe ORM with minimal boilerplate. Connect it to a Neon serverless Postgres database and you have a persistent, scalable backend for your bot without managing infrastructure.
AI commands are increasingly common in Discord bots. With the Anthropic SDK available on npm, you can add a /ask command that routes questions to Claude in a handful of lines. The bot token plus a Claude API key and you have a genuinely useful AI assistant living inside your Discord server.
Rate limiting becomes important once your bot is in more than a few servers. Discord enforces rate limits on API calls, and you want to handle them gracefully rather than having commands fail silently. The discord.js client handles basic rate limiting automatically, but you should add application-level limits for commands that call external APIs.
The scaffold you have built here handles all of these extensions cleanly. The dynamic command loading means adding features is a matter of dropping new files, not modifying the core bot logic.
The Bun Advantage in Practice
I have been building small Discord bots for a few years. The jump from a Node.js TypeScript setup to a Bun TypeScript setup is one of the more pleasant DX improvements I have made recently.
The configuration overhead that used to be table stakes, ts-node or tsx, dotenv, nodemon, a tsconfig that actually worked, is gone. You write TypeScript, you run it, it works. The developer loop is tighter because the tooling gets out of the way.
Discord.js v14 on Bun is stable and well-documented. The Bun team maintains official guides for it. There are community projects with thousands of stars running on this stack. It is not experimental.
If you are building a Discord bot in 2026, this is the setup I recommend without hesitation.