Your bot works perfectly. But every time a server admin wants to change the welcome message, update the auto-role, or see bot statistics, they have to come to you. A web dashboard puts configuration in admins' hands without requiring them to touch configuration files.
Architecture
Bot (discord.js) ─── Shared database ─── Dashboard (Express.js + Discord OAuth2)
│
Admin browser
Both the bot and the dashboard read/write the same database. When an admin changes a setting in the dashboard, the bot reads the updated setting on next use.
Discord OAuth2 Authentication
Admins log in via Discord - no separate account needed:
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const { Strategy: DiscordStrategy } = require('passport-discord');
passport.use(new DiscordStrategy({
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: process.env.CALLBACK_URL,
scope: ['identify', 'guilds']
}, async (accessToken, refreshToken, profile, done) => {
// Store user profile in session
return done(null, profile);
}));
app.get('/auth/discord', passport.authenticate('discord'));
app.get('/auth/callback', passport.authenticate('discord', {
successRedirect: '/dashboard',
failureRedirect: '/'
}));
// Middleware: only show dashboard to server admins
function requireGuildAdmin(req, res, next) {
const guild = req.user.guilds.find(g => g.id === req.params.guildId);
if (!guild || !(guild.permissions & 0x8)) { // 0x8 = ADMINISTRATOR
return res.status(403).send('Access denied');
}
next();
}
Guild Settings Panel
app.get('/dashboard/:guildId', requireGuildAdmin, async (req, res) => {
const settings = await db.query('SELECT * FROM guild_settings WHERE guild_id = $1', [req.params.guildId]);
res.render('dashboard', { settings: settings.rows[0] });
});
app.post('/dashboard/:guildId/save', requireGuildAdmin, async (req, res) => {
const { welcomeMessage, autoRoleId, logChannelId } = req.body;
await db.query(
`INSERT INTO guild_settings (guild_id, welcome_message, auto_role_id, log_channel_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (guild_id) DO UPDATE SET
welcome_message = $2, auto_role_id = $3, log_channel_id = $4`,
[req.params.guildId, welcomeMessage, autoRoleId, logChannelId]
);
res.redirect(`/dashboard/${req.params.guildId}?saved=true`);
});
The bot then reads from guild_settings table instead of hardcoded values.
Host your bot dashboard on Space-Node
Why most Discord bots eventually grow a dashboard
Slash commands are great until your config has more than 5 toggles. A dashboard wins for: per-guild settings, role/channel pickers, audit logs, and customer support without giving everyone admin commands.
Architecture that works
Browser -> Web (Next.js / SvelteKit) -> API (REST or tRPC) -> Bot process
| |
+---- OAuth2 (Discord) --------+
+---- Postgres (config) -------+
+---- Redis (cache/session) ---+
Don't run the bot inside the web process. The bot needs a long-lived gateway connection; web servers cycle on deploy. Two processes, one DB.
Discord OAuth2 scopes you actually need
| Scope | Required for |
|---|---|
| identify | login |
| guilds | list user's servers |
| guilds.members.read | check user's roles in a guild |
Do NOT request bot or applications.commands from the dashboard login. Those belong to the bot's own invite link.
Permission check pattern
A user is allowed to edit a guild's bot config only if:
- The bot is in that guild (cross-check your own DB).
- The user has
MANAGE_GUILDpermission in that guild.
const MANAGE_GUILD = 0x20;
const canManage = (BigInt(g.permissions) & BigInt(MANAGE_GUILD)) === BigInt(MANAGE_GUILD);
Never trust client-side filtering. Re-check on the API route.
Real-world trap: rate limits
Discord rate-limits the /users/@me/guilds endpoint at ~5 requests / 5 seconds per user. Cache the user's guild list in Redis for 60-300 s; otherwise a refresh-spamming user will get your bot rate-limited globally.
Making config changes take effect immediately
The web saves to Postgres. The bot caches config. Either:
- Bot re-reads on every command (simple, fine for low load).
- Use Redis pub/sub: web publishes
config:update:{guildId}, bot subscribes and invalidates its cache.
The pub/sub pattern is what lets the dashboard show "Saved" and have the bot honor the change in under a second.
