discord-embed-router
v1.2.1
Published
An Express-inspired router for discord.js embeds and interactions
Maintainers
Readme
discord-embed-router
An Express-inspired, URL-path-based router for discord.js interactions. Define routes once, render an embed for each, and get buttons/select menus/modals that "link" to them for free: no more hand-rolled customId parsing.
Why
Handling Discord message components usually means switching on customId strings by hand, threading state through them yourself, and hoping two features never pick the same prefix. discord-embed-router treats your bot's UI like a tiny web app instead:
- Routes: register a path (
/catalog/:id) and a handler that returns the embed/components to render, the same way you'd write an Express route and its response. - Component builders:
RouteButtonBuilder,RouteStringSelectMenuBuilder,RouteModalBuilder, etc. encode the destination path/query into the component'scustomIdfor you. - Sessions: attach per-message state that persists across clicks without you managing a cache.
- Cleanup/timeout: declare what a stale component should turn into once its window expires.
Install
npm install discord-embed-router discord.jsdiscord.js is a peer dependency: you bring your own compatible version (see peerDependencies in package.json for the supported range).
Quick start
import { EmbedRouter, RouteButtonBuilder } from "discord-embed-router";
import {
ActionRowBuilder,
ButtonStyle,
Client,
EmbedBuilder,
} from "discord.js";
const client = new Client({ intents: [] });
const router = new EmbedRouter<undefined, number>(client);
router.onError(console.error);
router.get("/counter", (embedRouter, interaction, state) => {
const value = state.session.get() ?? 0;
state.session.set(value);
return {
embeds: [new EmbedBuilder().setTitle("Counter").setDescription(`${value}`)],
components: [
new ActionRowBuilder()
.addComponents(
new RouteButtonBuilder(embedRouter)
.setLabel("+1")
.setStyle(ButtonStyle.Success)
.setTo("/counter/increment", { method: "POST" }),
)
.toJSON(),
],
// required whenever a route touches the session, so the router knows
// when to drop the stored count
timeout: 5 * 60 * 1000,
};
});
router.post("/counter/increment", (_embedRouter, _interaction, state) => {
state.session.set((state.session.get() ?? 0) + 1);
return { redirect: "/counter" };
});
client.on("interactionCreate", async (interaction) => {
if (
interaction.isChatInputCommand() &&
interaction.commandName === "counter"
) {
await router.dispatch(interaction, "/counter");
}
});
client.login(process.env.DISCORD_TOKEN);Clicking "+1" fires a POST /counter/increment, which mutates the count stored in the message's session and redirects to GET /counter to re-render. Storing the count in a session rather than baking it into the button's customId matters here: the router serializes dispatches per message, so a read-modify-write against the session can't race itself the way two rapid clicks would if each button's customId captured a stale value at render time (both clicks would send the same "value + 1" and one increment would be lost).
See examples/basic-bot for a full bot with multiple routes, a catalog page, and command wiring.
Core concepts
Routes
router.get/post/put/patch/delete(path, handler) registers a handler for a path, using path-to-regexp syntax for params (/user/:id). router.modal(path, handler) registers a handler that returns a modal to show instead of editing the message. A GET handler's return value is the new message content; other methods must return a redirect (to a registered GET path) or undefined (silent ack).
When one path implements several methods, router.route(path, handlers) registers them all at once from an object keyed by lowercase method, which is handy when a module already exports its handlers that way:
router.route("/counter", { get, post, modal } satisfies RouteHandlers<...>);Routers can be nested with router.use(prefix, subRouter), mirroring how you'd compose an Express app.
Component builders
RouteButtonBuilder, RouteStringSelectMenuBuilder, RouteChannelSelectMenuBuilder, RouteRoleSelectMenuBuilder, RouteUserSelectMenuBuilder, and RouteModalBuilder extend their discord.js counterparts, replacing setCustomId/setURL with .setTo(path, { method, query }). The router encodes the path into a compact customId and decodes it back on click.
Sessions
Pass a Session type parameter to EmbedRouter<Globals, Session, Locals> and use state.session.get()/set()/delete() inside a handler to keep state tied to a message across multiple interactions (e.g. a multi-step form). Any handler that sets a session or a cleanup must also return a timeout, so the router always knows when to give up on stale state.
Cleanup and timeouts
A GET handler can return { cleanup, timeout } alongside its content. If no further interaction lands on that message before timeout ms, cleanup runs and its return value (if any) is applied to the message, which is handy for expiring a form or disabling stale buttons.
Encoding
By default, paths are compacted with HashEncoder so customIds stay under Discord's 100-character limit even for deeply nested routes. Pass a custom Encoder via the EmbedRouter constructor if you need different encoding behavior.
API reference
Full type-level documentation is available via your editor's hover/autocomplete (all exports are documented with TSDoc). Public exports:
EmbedRouterRouteButtonBuilder,RouteModalBuilder,RouteStringSelectMenuBuilder,RouteStringSelectMenuOptionBuilder,RouteChannelSelectMenuBuilder,RouteRoleSelectMenuBuilder,RouteUserSelectMenuBuilderEncoder,HashEncoderConfigError
Changelog
Notable changes per release are documented in the changelog.
License
MIT
