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
Mapkeyed 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.
rendercounts the votes and draws a ten-block bar per option.interaction.updateedits 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
/endpollcommand that removes the buttons withinteraction.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. |