Skip to content

Start here

Build a Discord bot in TypeScript with seedcord, which types your commands, options, and component ids end to end.

If you have built a Discord bot, you have written the routing yourself. A switch on the command name, a tree of if/else on the custom id, one controller per interaction kind, all of it extended by hand each time you add a command. I wrote that too, and I copied it into every bot I started.

TypeScript couldn't help with the routing, and it couldn't help once you were inside the handler either. You registered the option yourself, but getString still handed back string | null. A custom id arrived as a plain string you parsed and cast into the thing you encoded. Every read opened with a check whose answer you already knew.

Running the bot to test it cost time too. Every tweak meant a restart. That meant waiting for it to log in, register its commands, and finish every other startup step before I could see my change in Discord.

Frameworks take the routing off you. Most put the routing, the checks, and the work in one handler, though. Each of those changes for a different reason, so a file holding all three gets edited every time any of them moves.

I built seedcord to fix all of that, on two principles:

  1. seedcord automates what it reliably can, but still lets you stay in control.
  2. Types cover everything seedcord can work out before your bot runs.

A bot with three commands and a bot with three hundred are written the same way.

seedcord is a TypeScript framework for Discord bots. A bot runs on one of two transports, the two ways Discord can reach it, and gateway or http helps you pick. Both run through Vite (opens in a new tab).

Note

seedcord is pre-1.0, so a minor version can break. Read the changelog before you bump.

What seedcord covers

Commands, replies, components, checks, events, plugins, and the dev tooling all come with the framework. You write your bot's features. A route is the id that ties a handler to its command, like ban. After seedcord codegen writes the types for your commands, a wrong route or option name is a compile error before the bot ever connects.

  • Commands. Slash commands, subcommands and groups, user and message context menus, autocomplete. A gateway or http server sends the commands it finds to Discord when it starts. A bot on Workers never does, so you deploy its commands with seedcord commands deploy.
  • Replying. Discord accepts one first response per interaction (opens in a new tab), then edits and follow-ups. seedcord tracks which one you already sent. If you call the wrong one, it throws an error that says what went wrong and usually which call to use. Throw a Notice from a handler to stop the command and reply to the user. A Notice can also log the error and publish it for telemetry.
  • Components. Buttons, select menus, modals, and pagination. Discord hands a click back as the custom_id string you built, so you parse it yourself. In seedcord you declare the fields once, so the handler gets them back typed. seedcord packs those fields, so more of them fit inside Discord's 100-character limit (opens in a new tab).
  • Checks. A gate runs before your handler and refuses by throwing. @Gated puts one on a handler, defineGate writes your own, and and or or combines them. seedcord ships a catalog you can import directly, like cooldowns and permission checks. Middleware runs around every handler call. A rate limiter counts uses against any key you build.
  • Events. On gateway, a handler can listen to the events discord.js emits. You can also publish and subscribe to your own events, and that works on http too.
  • Plugins. A plugin adds a database, a cache, or a client for another API to your bot. seedcord starts and stops it with the bot, and every handler reads it from this.core with its type.
  • Tooling. Save a handler and the running bot loads it without a restart. seedcord dev offers to re-register a command you changed. On http it also opens a tunnel and sets it as your Discord application's endpoint. The same CLI runs codegen, deploys and cleans up your commands, and builds your bot for production. The new project's lint rules flag a handler with no route decorator, like @SlashRoute, in your editor.

Errors that tell you what to do

Say you deferred, which tells Discord you'll reply later, then called reply():

reply() was called when this interaction was already deferred.
Use edit() to fill the deferred reply or followUp() for a new
message. (route slash:ban)

Or you packed a component id past what Discord accepts:

A customId encoded by "ticket" is 118 characters, but Discord allows
at most 100. Shorten the prefix, or move a field out of the customId.

Here you read a custom emoji by name before startup had filled it:

Emojis.wave has no value yet. Emojis fills during startup, but a read
at the top of a file runs before that. Read it inside a handler, or
after start() resolves.

discord.js validates a component through shapeshift, whose error nests several levels deep and never says which component or field failed. seedcord reports the one line that matters:

ProfileCard at components[0] failed to serialize: need a
ButtonBuilder or ThumbnailBuilder, got nothing.
(route slash:profile)

Errors elsewhere in seedcord follow the same shape. Each one says what happened, where, and what to do about it.

Your commands type your handlers

You declare an option once, on the command. The handler asks for it by name and gets back the type you declared.

src/handlers/Ban.ts
import { SlashHandler, SlashRoute } from '@seedcord/gateway';

@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        const target = this.options.getUser('target');
const target: User
await this.reply(`Banned ${target.username}`); this.options.getString('resaon');
Argument of type '"resaon"' is not assignable to parameter of type '"reason"'.
} }

The ban command, which isn't shown here, marks target as required, so it arrives as a plain User. If you leave target optional, the same call returns User | null. Every option you declare is typed, so a misspelled name stops the build, like resaon on the last line above.

Gateway and http differ

The sample is gateway, where getUser returns a discord.js User. On http the same call returns an APIUser, which is Discord's raw payload. Both carry username and id, but only the discord.js one has methods.

seedcord codegen imports your command files, constructs each command, and writes what the builders produced to seedcord-gen.d.ts. Run it again after you change a command, or say yes when seedcord dev offers to re-register.

What a seedcord project looks like

The generated seedcord-gen.d.ts sits beside your own files, in a tree the setup command creates.

my-bot
my-bot/
├─ src/
│  ├─ commands/
│  │  └─ Ping.ts
│  ├─ handlers/
│  │  └─ Ping.ts
│  ├─ events/
│  │  └─ Ready.ts
│  ├─ plugins/
│  │  └─ Uptime.ts
│  ├─ bot.ts
│  ├─ index.ts
│  └─ seedcord-gen.d.ts
├─ seedcord.config.ts
├─ .env
└─ package.json

src/commands/ holds what a command looks like on Discord. src/handlers/ holds the code that runs when someone uses one. src/events/ holds the handlers for everything else happening in a server. Only a gateway bot has it. src/plugins/ holds a small sample plugin. bot.ts is your bot's config, seedcord.config.ts configures the CLI, and seedcord-gen.d.ts is written by the CLI.

One command sets this up, along with the tsconfig, lint, and formatter files.

pnpm create seedcord

Gateway and http

You pick one transport when you create the project.

@seedcord/gateway holds a websocket connection through the discord.js client, so the events Discord sends about a server reach your bot, as far as the intents you ask for allow.

@seedcord/http answers the interaction requests Discord posts to your URL, and Discord posts only interactions there. seedcord checks each signature, rejects a stale or repeated request, and answers the check Discord runs before it accepts your URL. It runs on your own server, in an HTTP route of an app you already have, or on Cloudflare Workers. It has no discord.js dependency.

You write commands, handlers, and checks the same way on both. Gateway adds event handlers and hands your handlers discord.js objects. Http hands them Discord's raw payloads. Gateway or http helps you pick.

Your bot runs through Vite

seedcord dev, seedcord codegen, and seedcord build load your bot through Vite. A Vite plugin you return from vitePlugins in seedcord.config.ts runs in each of them. With vitePlugins: () => [yaml()] and @rollup/plugin-yaml, for example, a handler can import a .yaml file in dev and in the build. ENV=staging makes the CLI read your .env.staging files.

The same seedcord build makes whatever your host needs:

Prerequisites

  • Node 24.11 or newer, or Bun 1.4.2 or newer. Every seedcord package declares both under engines.
  • TypeScript. seedcord uses it heavily. You read and write generics from the first handler onward.
  • discord.js on gateway. The gateway transport uses the discord.js client, so gateway handlers read discord.js objects. An http bot has no discord.js dependency. You write command definitions with @discordjs/builders on both transports.