A web dashboard next to your bot

Run an Express dashboard and your discord.js bot in one Node process — the bot makes outbound connections, the web server takes your one public port. When it's a good idea, when to split into two servers, and the auth you must not skip.

Plenty of bots grow a small web page beside them — live stats, a status endpoint, a control panel. On Falix you can run both in one Node process: the discord.js bot makes only outbound connections, and the Express web server takes your server's single public port. This guide builds that combined process, shares state between the two halves, and is honest about the auth you must add and the point where two separate servers is the better call.

At a glance
You need a working discord.js bot and familiarity with Express, on the Node.js application
Plan Free or premium — free runs while the session timer has time
Time about thirty minutes

Why this works: one port, one process

Two facts from elsewhere in the guides combine neatly here:

  • A bot needs no port. It dials out to Discord and holds that connection — SERVER_PORT is irrelevant to it.
  • A web app needs the one port. Every application server gets exactly one public port, injected as SERVER_PORT, and your web server must bind 0.0.0.0 on it.

So there's no conflict: the web server claims SERVER_PORT, the bot ignores it, and both live in the same index.js. You get one process to start, one console to watch, and one server to keep online.

The combined index.js

Here's the whole thing — the bot, a shared stats object it updates, and an Express app that serves those stats:

require('dotenv').config();
const express = require('express');
const { Client, GatewayIntentBits, Events } = require('discord.js');

// --- the bot ---
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const stats = { ready: false, guilds: 0 };

client.once(Events.ClientReady, (c) => {
  stats.ready = true;
  stats.guilds = c.guilds.cache.size;
  console.log(`Bot connected as ${c.user.tag}`);
});

// --- the web dashboard, same process ---
const app = express();
app.get('/', (req, res) => {
  res.json({ ready: stats.ready, guilds: stats.guilds });
});

const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));

// Start the bot last; a bad token rejects here without taking the web server down.
client.login(process.env.DISCORD_TOKEN).catch((err) => {
  console.error('Bot login failed:', err.code || err.message);
});

The stats object is the bridge: the bot writes to it in ClientReady (and you'd update it wherever else you like), and the web route reads from it. Because they share one process, they share memory — no network call, no database, just a plain object both halves can see.

🎯 Good to know: The .catch on client.login matters here. If the token is wrong, you don't want the whole process to die and take the web server with it — catching the login failure keeps the dashboard up so you can still see what happened.

The online signal comes from the web server

The Node.js application flips your server to online when it sees the word Listening in the console — and here that line comes from app.listen's callback: Listening on port …. So the moment the web server is up, the panel shows green, even before the bot finishes connecting. That's fine — the two console.log lines (Listening on port … then Bot connected as …) tell you both halves are alive.

Auth: the part you cannot skip

Your dashboard is served on http://your-address:port, and that address is public. Anyone who finds it can hit every route you expose. A read-only stats page is harmless; the moment a route does something — restart the bot, change a setting, read private data — it needs real authentication in front of it.

⚠️ Heads up: Do not put controls behind "nobody knows the URL." They will. Before any route that acts, add proper login. The natural fit for a bot dashboard is Login with Discord — the same accounts your users already have — with a session check guarding each protected route. For the honest minimum on sessions and cookies, see Sessions, JWT, and the honest minimum.

Keep secrets — the bot token, the OAuth client secret — in .env, never in the page or the client-side code. See Environment variables & secrets.

When to split into two servers

One process is simplest, and for a stats page or a light control panel it's the right choice. Reach for two separate Falix servers — one running the bot, one running the web app — when:

  • The dashboard gets real traffic and you don't want web load competing with the bot for the same RAM.
  • You want to restart or redeploy one without dropping the other — reboot the website without disconnecting the bot.
  • The two have genuinely different lifecycles or scaling needs.

When you split them, they still need to talk — the web app has to read the bot's data. Don't expose that over the public internet: give both servers an Internal Network address and let them talk privately server-to-server, or have both read the same managed database. The database route is often cleanest: the bot writes stats to a table, the dashboard reads them, and neither has to reach into the other's process.

🎯 Good to know: Two servers means two of everything to keep online — on the free plan, two session timers. One combined process is one thing to babysit. Split when a real need above pushes you to, not before.

Verify it works

Start the server. The console shows Listening on port … and then Bot connected as …. Open your server's address (from the Network page) in a browser — you should get the JSON, {"ready":true,"guilds":N}, served by Express while the bot runs alongside it. In Discord, your commands still work. Both jobs, one process, confirmed.

Troubleshooting

  • Page won't load — the web half isn't binding correctly. It must use SERVER_PORT and 0.0.0.0, not a hard-coded port or localhost. See I can't reach my app.
  • Server shows offline even though the bot connected — the panel watches for Listening. Make sure your app.listen callback prints a line containing that word.
  • Whole process dies on a bad token — you didn't catch the login failure. Add the .catch on client.login so a token problem doesn't take the web server down.
  • The dashboard exposes controls with no login — stop and add auth first (Login with Discord). A public URL with power behind it is the classic mistake here.
  • Bot and web app fight over memory — that's the signal to split into two servers (above) and connect them over the Internal Network.

Next steps

Was this guide helpful?