Build a web leaderboard for your bot

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

The one deployment rule

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 schema this reads

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.

Step 1 — Add the web dependency

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"
  }
}

Step 2 — Create the leaderboard module

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 };

Step 3 — Start it from your bot

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 Listening in a console line is what flips the Node application to online — so this build prints it and doubles as your bot's "up" signal.

Step 4 — Try it

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.

Why read-only matters

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-sqlite3 reads 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.

Extend it

  • A pretty page: put an index.html in public/ that fetches /api/leaderboard/GUILD_ID and renders the rows into a styled list.
  • Show usernames, not IDs: the database stores Discord user IDs. Resolve them to names by having the bot store the username when it awards XP, or by calling Discord's API from the page's backend.
  • Pagination: the limit query param already caps at 100; add an OFFSET for "next page".

Troubleshooting

  • 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.
  • Empty leaderboard [] — 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.
  • Page won't load — the listen call must use SERVER_PORT and 0.0.0.0. See I can't reach my app.

Next steps

Was this guide helpful?