Collectors and paginated embeds

Long lists don't fit one embed. Build Previous/Next pagination with a message component collector — scoped to the right user, with a timeout — plus when to reach for a collector instead of a global handler.

An embed can only hold so much before it's a wall. The fix is pagination: show a slice, add Previous/Next buttons, and swap pages in place. The tool that makes it clean is a collector — it listens for button clicks on one message, from one user, for a set time, then tidies up. This recipe builds a paginated catalogue and explains when a collector beats a global handler.

At a glance
You need a working bot from the discord.js guide, and comfort with Slash commands in depth
Plan free or premium — free runs while your session timer lasts
Time about twenty minutes

A function that builds one page

Keep the page-building in one function so "render page N" is a single call. It slices the data, builds the embed, and disables the arrow that would run off the end:

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

const ITEMS = Array.from({ length: 23 }, (_, i) => `Item #${i + 1}`);
const PAGE_SIZE = 5;

function pageCount(items) {
  return Math.ceil(items.length / PAGE_SIZE);
}

function buildPage(items, page) {
  const total = pageCount(items);
  const start = page * PAGE_SIZE;
  const slice = items.slice(start, start + PAGE_SIZE);
  const embed = new EmbedBuilder()
    .setTitle('Catalogue')
    .setDescription(slice.join('\n'))
    .setFooter({ text: `Page ${page + 1} of ${total}` });
  const row = new ActionRowBuilder().addComponents(
    new ButtonBuilder().setCustomId('prev').setLabel('◀').setStyle(ButtonStyle.Secondary).setDisabled(page === 0),
    new ButtonBuilder().setCustomId('next').setLabel('▶').setStyle(ButtonStyle.Secondary).setDisabled(page === total - 1),
  );
  return { embeds: [embed], components: [row] };
}

setDisabled(page === 0) greys out on the first page, and setDisabled(page === total - 1) greys out on the last — so the user can never page past the ends.

Send it and collect the clicks

Reply with page 0, then attach a collector to that message. The collector watches for button clicks and calls you on each one:

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'catalogue') return;

  let page = 0;
  const message = await interaction.reply({ ...buildPage(ITEMS, page), fetchReply: true });

  const collector = message.createMessageComponentCollector({
    componentType: ComponentType.Button,
    filter: (i) => i.user.id === interaction.user.id,
    time: 60_000,
  });

  collector.on('collect', async (i) => {
    page += i.customId === 'next' ? 1 : -1;
    await i.update(buildPage(ITEMS, page));
  });

  collector.on('end', async () => {
    await interaction.editReply({ components: [] }).catch(() => {});
  });
});

What each option and event does:

  • fetchReply: true returns the sent message, which is what you attach the collector to.
  • componentType: ComponentType.Button limits the collector to button clicks (ignore other components).
  • filter decides whose clicks count. i.user.id === interaction.user.id means only the person who ran the command can page — someone else clicking is ignored, so two people browsing don't fight over one message.
  • time: 60_000 stops the collector after 60 seconds of the message's life.
  • collect fires on each accepted click. We move page and call i.update(), which edits the message to the new page — no new message, no "interaction failed".
  • end fires when the timer runs out. We strip the buttons with editReply({ components: [] }) so a dead message can't be clicked; the .catch() swallows the harmless error if the message was already deleted.

💡 Tip: Keep the current page in a plain variable in the command handler's scope, as above. Each browsing session gets its own page because each /catalogue runs the handler fresh.

A one-shot choice: awaitMessageComponent

When you only need one answer — a confirm/cancel, a yes/no — a full collector is overkill. awaitMessageComponent waits for a single click and resolves like a promise:

const msg = await interaction.reply({ content: 'Delete everything?', components: [confirmRow], fetchReply: true });
try {
  const click = await msg.awaitMessageComponent({
    filter: (i) => i.user.id === interaction.user.id,
    time: 15_000,
  });
  await click.update(click.customId === 'yes' ? 'Done.' : 'Cancelled.');
} catch {
  await interaction.editReply({ content: 'Timed out.', components: [] });
}

If nobody clicks in time it throws, which is why the try/catch — the catch is your timeout branch.

Collector or global handler?

Reach for a... when the buttons...
Collector belong to one message and one moment — pagination, a confirm prompt, a wizard
Global InteractionCreate + customId must work forever — reaction-role buttons, a persistent ticket-open button that still works after a restart

A collector dies with its timer (and with a restart); a reaction-role button routed by customId keeps working indefinitely because there's no collector to expire.

🎯 Good to know: Collecting typed messages (not button clicks) needs channel.createMessageCollector or channel.awaitMessages, and reading their text needs the Message Content privileged intent enabled on the Bot page. Button and select collectors need no such intent — another reason modern bots prefer components over "type your answer in chat".

Verify it works

Register /catalogue, start the bot, and run it. Page 1 of 5 shows with greyed out; moves forward, comes back, and the arrows grey out at each end. Leave it a minute and the buttons vanish — that's the end handler firing.

Troubleshooting

  • Buttons do nothing after a while — that's the time timeout; the collector ended and stripped them. Raise time, or re-run the command.
  • Anyone can page my private list — tighten the filter to i.user.id === interaction.user.id.
  • "This interaction failed" on click — you replied to the click instead of updating, or threw before answering. Use i.update(...) inside collect.
  • Buttons still clickable after a restart — collectors don't survive restarts. For permanent buttons, route by customId in a global handler instead of a collector.

Next steps

Was this guide helpful?