Build an image bot with sharp (rank cards)

Generate a rank-card image on the fly with sharp — which installs cleanly on the Node.js application — composite text and a progress bar over a background, and reply with the PNG.

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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[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.
  • composite layers it. A solid dark background is created with sharp({ create: … }), then the SVG is composited on top. .png().toBuffer() returns the finished image as bytes — no temp file needed.
  • AttachmentBuilder wraps those bytes as rank.png and editReply sends it. The deferReply up front buys time, since drawing an image takes a moment.
  • escapeXml keeps 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 rankCard and 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" (and serif/monospace) draw correctly with no setup. For a specific brand font, bundle the .ttf with 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.


Next steps

Was this guide helpful?