Some of the most-loved bot features are pictures: rank cards, welcome banners, profile badges. This recipe builds a /rank command in discord.js that draws a rank card — a name, level, and XP progress bar — and posts it as an image. It uses sharp, a fast image library that installs cleanly on the Node.js application because it ships prebuilt binaries (no compiler needed).
| At a glance | |
|---|---|
| You need | A working bot from Host a discord.js bot |
| Plan | Free or premium — no premium features required |
| Time | About twenty-five minutes |
New here? Build the base bot with Host a discord.js bot first.
Add the package
Open the Packages page in your server menu and install sharp. It downloads a prebuilt binary for the server's Linux environment, so there's nothing to compile and no build step to wait on. Restart when it finishes.
The whole bot
Here's the complete index.js. It composes the card as an SVG overlay on a solid background, then rasterizes it to PNG.
require('dotenv').config();
const { Client, GatewayIntentBits, Events, AttachmentBuilder, SlashCommandBuilder } = require('discord.js');
const sharp = require('sharp');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
function escapeXml(s) {
return s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
}
async function rankCard({ username, level, xp, nextXp }) {
const width = 480;
const height = 140;
const pct = Math.max(0, Math.min(1, xp / nextXp));
const barWidth = Math.round((width - 40) * pct);
const background = sharp({
create: { width, height, channels: 4, background: { r: 43, g: 45, b: 49, alpha: 1 } },
});
const overlay = Buffer.from(
`<svg width="${width}" height="${height}">
<text x="20" y="45" font-family="sans-serif" font-size="26" fill="#ffffff">${escapeXml(username)}</text>
<text x="20" y="75" font-family="sans-serif" font-size="18" fill="#b5bac1">Level ${level} • ${xp}/${nextXp} XP</text>
<rect x="20" y="100" width="${width - 40}" height="18" rx="9" fill="#1e1f22"/>
<rect x="20" y="100" width="${barWidth}" height="18" rx="9" fill="#5865f2"/>
</svg>`,
);
return background.composite([{ input: overlay, top: 0, left: 0 }]).png().toBuffer();
}
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([
new SlashCommandBuilder().setName('rank').setDescription('Show your rank card').toJSON(),
]);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'rank') return;
await interaction.deferReply();
const png = await rankCard({ username: interaction.user.username, level: 4, xp: 240, nextXp: 500 });
const file = new AttachmentBuilder(png, { name: 'rank.png' });
await interaction.editReply({ files: [file] });
});
client.login(process.env.DISCORD_TOKEN);
Restart and run /rank — the bot replies with a rank-card PNG.
How it works
- Build with SVG, rasterize with sharp. The card is described as a small SVG string — text for the name and level, two rounded rectangles for the progress bar (a dark track and a blue fill sized to the XP percentage). Sharp turns that SVG into pixels.
compositelayers it. A solid dark background is created withsharp({ create: … }), then the SVG is composited on top..png().toBuffer()returns the finished image as bytes — no temp file needed.AttachmentBuilderwraps those bytes asrank.pngandeditReplysends it. ThedeferReplyup front buys time, since drawing an image takes a moment.escapeXmlkeeps a username with a<or&in it from breaking the SVG.
🎯 Good to know: The example uses fixed numbers (level 4, 240/500 XP). In a real bot you'd read those from your XP store — see the leveling system recipe, which keeps per-user XP in SQLite. Feed its values into
rankCardand you have a live rank card.
Fonts and avatars
- Text renders out of the box. The server's environment includes the DejaVu font family, so
font-family="sans-serif"(andserif/monospace) draw correctly with no setup. For a specific brand font, bundle the.ttfwith your project and reference it in the SVG. - Adding the user's avatar is a natural next step: fetch
interaction.user.displayAvatarURL({ extension: 'png' }), download the bytes, and composite them as a second layer (round them off with an SVG circle mask). That's an extra network fetch per card, so cache avatars if your bot is busy.
⚠️ Heads up: Image work uses memory, and the free plan shares 2.5 GB of RAM. A few rank cards are nothing, but generating large images in a tight loop can hit the ceiling — if the bot gets killed under load, see Out of memory.
Verify it works
Run /rank and confirm an image appears with your username, the level line, and a partly-filled blue progress bar. Change the fixed xp value in the code, restart, and confirm the bar length changes.
Troubleshooting
| Symptom | Fix |
|---|---|
Cannot find module 'sharp' |
Install it from the Packages page and restart. |
| Text is missing but shapes render | You referenced a font that isn't installed. Stick to sans-serif/serif/monospace, or bundle a .ttf and name it in the SVG. |
| "This interaction failed" | Image generation took too long before a reply — deferReply() first (as shown), then editReply. |
| Bot killed under load | Too much image work at once for the RAM available — see Out of memory. |
💡 Tip: Need heavier drawing — real fonts, gradients, arbitrary shapes? Libraries built on a full canvas engine exist for Node too. Sharp is the recommendation here because its prebuilt binaries install without a compiler, which is exactly what you want on a hosted server.