Only show guides for your app
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 |
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.
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
pagein a plain variable in the command handler's scope, as above. Each browsing session gets its ownpagebecause each/catalogueruns the handler fresh.
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.
| 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".
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.
time timeout; the collector ended and stripped them. Raise time, or re-run the command.filter to i.user.id === interaction.user.id.i.update(...) inside collect.customId in a global handler instead of a collector.