A sane, opinionated template for discord bots written in typescript using the discord.js library.
For new, node lts (currently v24) projects.
Uses:
- biome for linting and formatting
- commitlint for linting commit messages
- husky for git hooks
- lint-staged for checks on commit
- vitest for testing
- tsx for dev time typescript
- varlock for env validation and parsing
- @mkvlrn/result for error handling
- variables
DISCORD_CLIENT_ID,DISCORD_CLIENT_TOKEN, andLOG_LEVELfilled in.envfile (see.env.schema) - optionally,
DEV_SERVERwith your test server ID for faster command registration during development - a notion of what a discord bot is and how
discord.jsworks - a server to test the bot on
Runs the project in watch mode.
Builds/transpiles the code to ./build.
Runs the built project.
Runs tests with vitest.
Runs biome in fix mode (only safe fixes) to lint and format the project.
Runs type checking using tsc.
Registers slash commands globally, or to the dev server if --dev flag is provided (requires DEV_SERVER env var).
Unregisters slash commands globally, or from the dev server if --dev flag is provided (requires DEV_SERVER env var).
Commands are auto-loaded from ./src/commands/. Just create a file and call createBotCommand.
Note: Discord requires command names to be lowercase. Use kebab-case for multi-word commands (e.g., my-command).
- Create a new file in
./src/commands/(e.g.,my-command.ts) - Call
createBotCommandwith your command definition:
import { SlashCommandBuilder } from "discord.js";
import { createBotCommand } from "#modules/commands";
createBotCommand({
data: new SlashCommandBuilder().setName("my-command").setDescription("Does something"),
async execute(interaction) {
await interaction.reply("Hello!");
},
});- Run
pnpm registerto register commands globally (orpnpm register --devfor your dev server) - Restart your bot
For commands with buttons, select menus, or modals, add a followUp handler. Use a prefix in customId to route interactions back to your command:
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { createBotCommand, type FollowUpInteraction } from "#modules/commands";
createBotCommand({
data: new SlashCommandBuilder().setName("counter").setDescription("A simple counter"),
async execute(interaction) {
const button = new ButtonBuilder()
.setCustomId("counter:increment") // prefix must match command name
.setLabel("Click me")
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await interaction.reply({ content: "Count: 0", components: [row] });
},
async followUp(interaction: FollowUpInteraction) {
if (interaction.isButton()) {
await interaction.reply("Button clicked!");
}
},
});- Delete the file from
./src/commands/ - Run
pnpm unregister(orpnpm unregister --dev) - Restart your bot
The template includes several examples demonstrating different patterns:
| Command | Description |
|---|---|
ping |
Simple reply |
roll |
Slash command with options (dropdown selection) |
roll-plus |
String input parsing with image generation |
roll-panel |
Interactive buttons and select menus with followUp |
src/
├── commands/ # Drop command files here — auto-loaded
│ ├── ping.ts
│ ├── roll.ts
│ ├── roll-panel.ts
│ └── roll-plus.ts
├── modules/
│ ├── bot.ts # Client setup, login, graceful shutdown
│ ├── commands.ts # createBotCommand + auto-loader
│ ├── interaction.ts # Dispatches interactions to commands
│ └── logger.ts # Pino logger config
├── utils/ # Shared utilities (dice rolling, image gen)
└── main.ts # Entry pointManaged by varlock with full type safety:
| Variable | Description |
|---|---|
DISCORD_CLIENT_ID |
Your Discord application's client ID |
DISCORD_CLIENT_TOKEN |
Your Discord bot token |
LOG_LEVEL |
Logging level (trace, debug, info, warn, error, fatal) |
See .env.schema for the schema definition.
You might want to install the recommended extensions in vscode. Search for @recommended in the extensions tab, they'll show up as "workspace recommendations".
If you have been using eslint and prettier and their extensions, you might want to disable eslint entirely and keep prettier as the formatter only for certain types of files.
This is done by the .vscode/settings.json file.
Debug configurations are also included (for source using tsx and for bundle using the generated source maps).