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: truereturns the sent message, which is what you attach the collector to.componentType: ComponentType.Buttonlimits the collector to button clicks (ignore other components).filterdecides whose clicks count.i.user.id === interaction.user.idmeans 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_000stops the collector after 60 seconds of the message's life.collectfires on each accepted click. We movepageand calli.update(), which edits the message to the new page — no new message, no "interaction failed".endfires when the timer runs out. We strip the buttons witheditReply({ 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
pagein a plain variable in the command handler's scope, as above. Each browsing session gets its ownpagebecause each/catalogueruns 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.createMessageCollectororchannel.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
timetimeout; the collector ended and stripped them. Raisetime, or re-run the command. - Anyone can page my private list — tighten the
filtertoi.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(...)insidecollect. - Buttons still clickable after a restart — collectors don't survive restarts. For permanent buttons, route by
customIdin a global handler instead of a collector.