Only show guides for your app
A web page that reads your leveling bot's SQLite database and shows a live ranking — the full Node.js build, with the read-only database pattern that keeps the bot's data safe.
If you've built a leveling bot, its XP data is sitting in a SQLite file doing nothing on the web. This guide puts a public leaderboard in front of it: an Express page that reads the same database your bot writes and shows the top players. The trick is reading it safely — open the file read-only so the web side can never corrupt the bot's data.
| At a glance | |
|---|---|
| You need | A leveling bot writing a SQLite file (see the leveling-system recipe) |
| Build with | Express + better-sqlite3, reading the bot's database read-only |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About twenty-five minutes |
A SQLite database is a single file on disk. Two separate Falix servers are two separate containers — they cannot share that file. So the leaderboard has to run on the same server as the bot, where levels.db actually lives. The clean way to do that is the "one process, two jobs" pattern: your bot process also starts the web server. That's exactly what A web dashboard next to your bot covers, and this build slots into it.
🎯 Good to know: If you'd rather keep the bot and the website on different servers, don't use a SQLite file — point both at a shared managed database (MySQL or Postgres) instead. A managed database lives outside your containers, so any number of servers can read it. This guide takes the same-server SQLite route because it's the simplest.
The leveling-system recipe stores XP in a table exactly like this:
CREATE TABLE IF NOT EXISTS levels (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
last_msg INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (guild_id, user_id)
);
Two things worth noticing: XP is stored per server (the primary key is (guild_id, user_id)), and there is no level column. The bot stores total XP and computes the level from it with the Mee6 curve — so the leaderboard does the same, reusing the bot's exact formula. If your bot named things differently, adjust the query and curve to match.
Your bot already has a package.json. Add Express and better-sqlite3 to its dependencies (the bot likely uses better-sqlite3 already):
{
"dependencies": {
"discord.js": "^14.15.3",
"better-sqlite3": "^11.1.2",
"express": "^4.19.2"
}
}
Create leaderboard.js. It opens the bot's database read-only and serves a JSON ranking plus a page:
const express = require('express');
const Database = require('better-sqlite3');
const path = require('node:path');
// The SAME Mee6 curve the leveling bot uses, so our levels match its /rank.
function xpToNext(level) { return 5 * level * level + 50 * level + 100; }
function levelFromXp(totalXp) {
let level = 0;
while (totalXp >= xpToNext(level)) { totalXp -= xpToNext(level); level++; }
return level;
}
function startLeaderboard() {
// Open the SAME file the bot writes — but read-only, so this side can
// never change or corrupt the bot's data.
const db = new Database(path.join(__dirname, 'levels.db'), {
readonly: true,
fileMustExist: true,
});
const app = express();
app.use(express.static(path.join(__dirname, 'public')));
// Matches the leveling-system schema: levels(guild_id, user_id, xp, last_msg).
// No `level` column — we compute it from total xp below.
const topStmt = db.prepare(
`SELECT user_id, xp
FROM levels
WHERE guild_id = ?
ORDER BY xp DESC
LIMIT ?`
);
app.get('/api/leaderboard/:guildId', (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 100);
const rows = topStmt.all(req.params.guildId, limit);
res.json(rows.map((r, i) => ({
rank: i + 1,
user_id: r.user_id,
xp: r.xp,
level: levelFromXp(r.xp),
})));
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Leaderboard listening on port ${PORT}`));
}
module.exports = { startLeaderboard };
In your bot's index.js, add one line near the top (after the requires):
require('./leaderboard').startLeaderboard();
Now the single process logs in to Discord and serves the leaderboard on your public port. Press Start; alongside the bot's login line you'll see Leaderboard listening on port ….
🎯 Good to know: The word
Listeningin a console line is what flips the Node application to online — so this build prints it and doubles as your bot's "up" signal.
Open your server's address (from the Network page) with a guild (server) ID:
http://YOUR_ADDRESS:PORT/api/leaderboard/YOUR_GUILD_ID
You get the top players, already ranked, with the level worked out from each XP total:
[
{ "rank": 1, "user_id": "…", "xp": 9100, "level": 13 },
{ "rank": 2, "user_id": "…", "xp": 4200, "level": 9 }
]
The query filters by guild_id, orders by xp descending, and the code numbers the ranks and computes each level with the same curve as the bot — so each server gets its own board and the levels match /rank exactly.
Two things share levels.db: the bot (writing new XP constantly) and the web reader. Opening the reader with readonly: true is a hard guarantee that a bug on the web side can never write, delete, or lock the table against the bot. fileMustExist: true also makes the app fail fast with a clear error if the database isn't there yet — better than silently creating an empty one and showing a blank board.
💡 Tip:
better-sqlite3reads are synchronous and extremely fast, which is perfect here — a leaderboard is a handful of rows. It ships prebuilt binaries, so it installs on Falix with no compiler step. See Store data for your bot for the write side.
index.html in public/ that fetches /api/leaderboard/GUILD_ID and renders the rows into a styled list.limit query param already caps at 100; add an OFFSET for "next page".SqliteError: unable to open database file / "file must exist" — the bot hasn't created levels.db yet, or the path is wrong. Start the bot once so it writes the file; confirm levels.db sits next to leaderboard.js.[] — no rows for that guild ID yet, or the ID is wrong. Award some XP in that server first, and double-check the guild ID.Cannot find module 'better-sqlite3' or 'express' — add them to package.json and restart, or install from the Packages page.listen call must use SERVER_PORT and 0.0.0.0. See I can't reach my app.