How I Structure My Discord Bots and Their Dashboard
A tour of the architecture behind my projects: a Bun monorepo with file-based routing, plugins instead of god classes, buttons that survive restarts, and a typed contract between the bots and a Next.js dashboard.
Most of what I build these days is one system made of three repositories:
- The bots: a monorepo of six Discord bots (moderation, security, stats, welcome and more) plus an API, built for one large community server.
- priw: the same framework, cut down to one bot whose features are modules a server switches on and off. It's meant for smaller, relaxed communities.
- The dashboard (dash.vante.app): a Next.js app where server owners change settings, read logs, handle appeals and watch statistics for either kind of deployment.
The first version of the bots was what most Discord bots end up as: one big src/ folder, a command handler with an if chain, settings cached separately in every process and PM2 holding it all together. This post covers what replaced that, and why.
The shape of the system
┌──────────────────────────────┐
│ Dashboard (Next.js 16) │
│ Better Auth · Drizzle/Neon │
└──────────────┬───────────────┘
│ typed contract (zod), tRPC over HTTPS
│ one API token per server
┌──────────────────────────────▼───────────────────────────────┐
│ VDS │
│ apps/api (Bun.serve + tRPC) apps/<bot> × N (discord.js) │
│ │ │ │
│ └──── @repo/db (Drizzle) ──┴──── Redis pub/sub ───│
│ Postgres Redis │
└──────────────────────────────────────────────────────────────┘The dashboard never touches the bots' database. It has its own database for users, sessions and which servers they can access, and it asks each server's bot API for everything else. That boundary is what lets the same dashboard drive both the big multi-bot deployment and priw.
A monorepo with no build step
apps/ one folder = one Discord application (or the API)
packages/
@vante/ the framework: core, builders, loaders, logs, stores, image
@repo/ shared, not bot-specific: db, env, utils
@plugin/ features: moderation, security, economy, stats, tickets…
scripts/ supervise, build, deployThree scopes, three meanings. @vante/* is the bot framework and knows nothing about economies or antinuke. @repo/* is anything the API can import, which mostly means no discord.js. @plugin/* is where features live.
There's no Turbo and no tsup. Nothing here gets published, so Bun runs every package's TypeScript straight from source. Turbo orchestrates build graphs, and with no build step there's no graph to orchestrate. bun run --parallel and bun --filter cover the rest. There's one tsconfig.json and one biome.json at the root, and Biome does formatting, linting and import sorting in a single tool.
Bun also replaced a pile of dependencies: Bun.SQL for pg, Bun.RedisClient for redis, Bun.serve routes for Hono, Bun.cron for croner, Bun.Glob for fast-glob, and a 40-line supervise.ts for PM2.
File-based routing for a Discord bot
If you've used the Next.js App Router, this will look familiar:
apps/mainframe/src/app/
├── commands/
│ ├── (moderation)/ban.ts → "ban", category "moderation"
│ └── (staff)/staff/points.ts → "staff points"
├── components/
│ └── (founder)/secretroom/lock.ts → button id "secretroom:lock"
├── modals/(moderation)/report.ts → modal id "report"
├── contexts/(moderation)/user-info.ts → right-click menu "User Info"
├── events/(staff)/voice-state-update.ts
├── crons/staff-reset.ts
└── utils/ → never loaded as routesThe loader enforces three rules the old repo left to convention: the filename is the command name, so a name: field can't disagree with the file it's in. The (folder) is the category, which the help command reads. Every route file has exactly one default export. Everything else goes in utils/, reached through a package.json subpath import (#utils), so it always means this app's utils.
Buttons used to live in a huge interactions.ts that if-chained on customId. Now a button is a file, and the router matches ids longest-prefix first, so secretroom:userAction:kick:123 finds secretroom/userAction.ts and hands it { action: 'kick', targetId: '123' } as typed params.
One command definition, two ways to run it
My communities are Turkish and prefix-driven. People type .ban @user spam, but slash commands still matter. So a command declares its options once, and both paths are generated from that single list:
export default defineCommand({
description: 'Sunucudan bir kullanıcıyı yasaklamanızı sağlar.',
aliases: ['yasakla'],
options: [
{ name: 'user', type: 'user', description: '…', required: true },
{ name: 'reason', type: 'string', description: '…' },
],
async execute(ctx) {
const target = ctx.options.getMember('user');
const reason = ctx.options.getString('reason') ?? 'Sebep belirtilmedi.';
await ctx.respond.reply(`${target.user.tag} yasaklandı.`);
},
});The slash command body is generated from options. The prefix parser consumes tokens against the same array, so the two can't drift apart. execute can't tell which path it came from. A few details make the prefix side feel native: the last string option swallows the rest of the message (no quotes needed for a reason), choices and min/max are enforced on both paths, and booleans accept evet/hayır as well as yes/no.
Plugins instead of a god class
The framework's Bot class deliberately knows nothing about moderation or statistics. An app installs the plugins it needs:
new Bot({
name: 'Security',
token: env.SECURITY_TOKEN,
root: import.meta.dir,
plugins: [security(), moderation()],
});A plugin attaches its manager and widens Bot's type with declaration merging, so call sites read naturally as bot.moderation.punish(...):
declare module '@vante/core' {
interface Bot {
readonly stats: StatisticsManager;
}
}
export function stats() {
return (bot: Bot) => void Object.assign(bot, { stats: new StatisticsManager(bot) });
}The catch: declare module is global. Every app sees bot.stats in its types whether or not it installed the plugin, so forgetting to install one compiles fine and gives you undefined at runtime. I accept that trade-off because events and crons receive a Bot, not a command context, and most call sites go through bot.X. A test checks the plugins that depend on each other: security needs moderation, because the antinuke punishes through bot.moderation.
Three layers inside a plugin
In priw, each plugin is split by what it's allowed to import:
| File | Imports | Used by |
|---|---|---|
logic.ts | nothing from Discord, nothing from the database | bot and API: the rules |
repo.ts | Drizzle and the Db type only | bot and API: the queries |
index.ts | discord.js, @vante/core | bot only: the manager |
The API imports only logic and repo, so it never loads discord.js. It starts fast and stays small, and the rules can't depend on a gateway connection by accident.
Buttons that survive a restart
A Discord message can sit in a channel for months, and someone will click its button long after the process that sent it has restarted. Anything held in memory, like a collector or a map of pending modals, is gone by then.
So the state lives in the customId:
- Modals encode their params positionally (
report:111:222) and decode them on submit. The encoder rejects a param containing:and fails at the call site if you go over Discord's 100-character limit, instead of letting Discord return an opaque error. - Replay buttons encode a whole command invocation.
.zarshows bet buttons, and each button's id is literally "runzarwithbet=500", packed as base36 to fit. Clicking one runs the command again through the normal middleware and cooldowns, and the result replaces the panel in place.
Component handlers are stateless: they read what they need from the database on every click. Only multi-step setup wizards keep state in a closure (the panel() helper), and their ids are deliberately left unregistered so a click is never handled twice.
Settings: one schema, one store, one invalidation channel
Server settings are a single zod schema with a default for every field. The database stores only what a server changed, and resolveSettings never throws: a bad value falls back to its own default. A typo in a JSON column can't take a bot down.
Reads go through GuildStore, which caches the resolved settings, shares in-flight reads and inserts a missing row once. Writes go through patch(), a deep merge under a row lock (SELECT … FOR UPDATE). A dashboard save and the bot's own writes use the same code path, so two saves can't overwrite each other.
When the dashboard saves, the API publishes on a Redis channel, and every bot process drops its cached copy. The old version needed a hand-rolled pub/sub setup to keep five separate in-process caches in sync. Now it's one channel and one store.
Replies: Components V2, wrapped once
Every reply goes through a Container builder over Discord's Components V2. It enforces the rules Discord only reports as a generic 400: V2 messages can't mix in content or embeds, ephemeral replies need both flag bits, and the 40-component and 4,000-character limits apply to the total across nested components, not per component. assertWithinLimits walks the tree and tells you which limit you crossed and by how much.
Image cards (rank cards, leaderboards) are JSX rendered with satori and resvg rather than canvas drawing calls. Fetching avatars once, in parallel, with a cache took a rank card from about 53 ms to 11 ms.
The dashboard
The dashboard is Next.js 16 with the App Router, Better Auth (including passkeys), Drizzle, next-intl for Turkish and English, and TanStack Query, Form and Table. It's organized by feature (src/features/moderation, src/features/economy…) rather than by file type.
A typed contract between two repos
The most important file in the dashboard is contract.ts. Every bot API call is declared once there, with its tRPC procedure name, its zod input and output schemas, and the permission it needs:
const overview = await callBot(guildId, 'statsOverview');
// ^ typed from the contract and validated against it at runtimeThe server client, the browser-to-server proxy allowlist, the TanStack Query hooks and the MSW mocks are all derived from that one file. callBot checks the user's session and permission on every call, then validates the bot's response against the schema. If the bots change a response shape, the dashboard fails loudly at the boundary instead of rendering undefined three components down.
Since the contract describes what a bot answers, priw implements the same procedures in the same shapes. As far as the dashboard is concerned, it's just another bot.
Treating the bot URL as untrusted
Server owners register their bot's API URL and token, which means the dashboard makes requests to user-supplied URLs. That's a textbook SSRF risk, so:
- The URL guard resolves DNS and refuses private, loopback, link-local and CGNAT ranges, including IPv4 addresses mapped into IPv6.
- API tokens are stored encrypted with AES-256-GCM. A deterministic HMAC of the token sits next to it, so the dashboard can look up a server by its key without decrypting every row.
Deploying: one command
bun vds deploy type-checks the project, packs it, ships it over SSH and runs a setup script on the server. The first time, that installs everything: Postgres and Redis in Docker bound to localhost, Bun, a systemd service running bun start, Caddy in front and ufw rules. After that it's bun install, migrations and a restart. Each deployment has its own ports, service name and Caddy site file, so priw and the main bots can share a machine.
What I'd tell myself a year ago
- Make the structure enforce the rules. "The filename is the command name" in a README gets ignored. A loader that refuses anything else can't be ignored.
- Assume every click arrives twice, after a restart. Put state in the
customIdor the database, never in a closure you can't get back. - Draw the import boundaries early. "The API never loads discord.js" is easy to keep from day one and painful to retrofit.
- Put one contract between repos. Two repos that agree on shapes by convention will eventually disagree in production.
- Delete dependencies when the runtime catches up. Bun replaced Hono, PM2,
pg,redis,cronerandfast-globin this project, and each one removed was one less thing to update.
If you want to know how the database side works, including the race conditions and the Drizzle setup, I wrote about that in Why I Moved From Prisma to Drizzle.