Build a poll bot

A /poll command with button voting and a live bar-chart embed that updates on every click — one vote per person, and an honest note about why active polls reset on restart.

This recipe builds a poll bot in discord.js: one /poll command with a question and options, buttons to vote, and an embed that redraws a little bar chart every time someone clicks. Buttons (rather than reactions) give you exact control — one vote per person, and people can change their mind.

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 minutes

New here? Start with Host a discord.js bot.

Buttons or reactions?

You could build a poll with emoji reactions, but buttons win for real voting:

Buttons Reactions
One vote per person Yes — you control it in code No — people can add every emoji
Change a vote Yes — clicking a new option replaces the old Clumsy — remove one, add another
Live tallies Yes — edit the embed on each click Manual — count reactions yourself
Extra intents None Needs the reactions intent + partials

This recipe uses buttons.

The whole bot

Here's the complete index.js. Options are passed as one string separated by |.

require('dotenv').config();
const {
  Client, GatewayIntentBits, Events,
  ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, SlashCommandBuilder,
} = require('discord.js');

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

// messageId -> { question, options, votes: Map<userId, optionIndex> }
// In memory: a restart wipes every active poll. See bot-data-storage to persist.
const polls = new Map();

function render(poll) {
  const counts = poll.options.map(() => 0);
  for (const choice of poll.votes.values()) counts[choice]++;
  const total = poll.votes.size || 1;
  const lines = poll.options.map((opt, i) => {
    const pct = Math.round((counts[i] / total) * 100);
    const filled = Math.round(pct / 10);
    return `**${opt}**\n${'█'.repeat(filled)}${'░'.repeat(10 - filled)} ${counts[i]} (${pct}%)`;
  });
  return new EmbedBuilder().setTitle(`📊 ${poll.question}`).setDescription(lines.join('\n\n')).setColor(0x5865f2);
}

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set([
    new SlashCommandBuilder()
      .setName('poll')
      .setDescription('Start a poll (up to 5 options)')
      .addStringOption((o) => o.setName('question').setDescription('The question').setRequired(true))
      .addStringOption((o) => o.setName('options').setDescription('Options separated by |').setRequired(true))
      .toJSON(),
  ]);
  console.log(`Listening as ${c.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand() && interaction.commandName === 'poll') {
    const question = interaction.options.getString('question');
    const options = interaction.options.getString('options').split('|').map((s) => s.trim()).filter(Boolean).slice(0, 5);
    if (options.length < 2) {
      await interaction.reply({ content: 'Give at least two options, separated by `|`.', ephemeral: true });
      return;
    }
    const poll = { question, options, votes: new Map() };
    const row = new ActionRowBuilder().addComponents(
      options.map((opt, i) =>
        new ButtonBuilder().setCustomId(`poll:${i}`).setLabel(opt.slice(0, 80)).setStyle(ButtonStyle.Secondary)),
    );
    const message = await interaction.reply({ embeds: [render(poll)], components: [row], fetchReply: true });
    polls.set(message.id, poll);
    return;
  }

  if (interaction.isButton() && interaction.customId.startsWith('poll:')) {
    const poll = polls.get(interaction.message.id);
    if (!poll) {
      await interaction.reply({ content: 'This poll is no longer active.', ephemeral: true });
      return;
    }
    poll.votes.set(interaction.user.id, Number(interaction.customId.split(':')[1]));
    await interaction.update({ embeds: [render(poll)] });
  }
});

client.login(process.env.DISCORD_TOKEN);

Restart, then run /poll question:"Pizza toppings?" options:"Pepperoni | Mushroom | Pineapple".

How it works

  • One vote per person, changeable. Votes live in a Map keyed by user ID. Setting the same key again overwrites the old choice — so a second click just moves your vote, it doesn't add one. That's why the percentages always add up.
  • The embed is the scoreboard. render counts the votes and draws a ten-block bar per option. interaction.update edits the original message in place, so everyone watching sees the new numbers instantly — no extra messages.
  • Up to five options fit in one action row (Discord's limit is five buttons per row). Extras are dropped by .slice(0, 5); button labels cap at 80 characters.

🎯 Good to know: Votes are anonymous — the bot stores who voted (to enforce one-per-person) but never shows it. If you want to close a poll, add a /endpoll command that removes the buttons with interaction.message.edit({ components: [] }).

What resets on a restart

Every poll lives in the polls Map in memory, so a restart ends all active polls — the buttons will answer "This poll is no longer active." For quick polls that's usually fine. To let polls survive restarts, save each poll's votes to SQLite and reload them on boot; Storing data for your bot shows the pattern, and the giveaway bot is a worked example of the same idea.

Verify it works

Start a poll, click an option, and watch the bar and percentage jump. Click a different option and confirm your vote moves rather than adds (the total stays the same). Vote from a second account to see the tallies climb.

Troubleshooting

Symptom Fix
"Give at least two options" You gave fewer than two options — separate every option with the vertical-bar character exactly as the usage example above shows.
Only some options appear Discord allows five buttons per row; extras past five are trimmed.
Old polls say "no longer active" Expected after a restart — the poll state is in memory. Persist it to keep polls alive.
"This interaction failed" An error was thrown before replying — check the console and see Crash-proof your bot.

Next steps

Was this guide helpful?