Custom Commands homeCustom Commands
  • Blog
  • Premium
  • Templates
  • Support
Back to blog
// blog

How to paginate Discord embeds with buttons using custom commands bot

Learn how to split a Discord message into pages your members can click through, using the paginator built into custom commands bot.

Md Shahriyar Alam

20 days ago

·13 min read

One Discord message, several pages, buttons to move between them.

You add the pages. The paginator draws the buttons, tracks which page is showing, greys out Prev on the first page and Next on the last, and closes itself when nobody is clicking.

A page is whatever you want on screen — text, an embed, a file. It does not have to be a list of anything.

await p.start() waits until the pages close, then hands back how they ended. The lines after it run once the paginator is finished, which is what reading a script top to bottom would lead you to expect.

Everything below goes in the code box of a slash command or a text command.


Quickstart

python
p = PaginatorV2(interaction=interaction)

p.add_page("First page")
p.add_page("Second page")
p.add_page("Third page")

await p.start()

Three buttons appear: Prev, Next, Stop. Only the person who ran the command can use them. After three minutes without a click, the session ends and the buttons grey out.


Pages

add_page() accepts three shapes. Use whichever suits the page.

A plain string

python
p.add_page("Just some text")

A Message

The same builder you use everywhere else in a script.

python
p.add_page(Message(
    title="Server rules",
    description="Be kind.",
    color="#5865F2",
    footer="Page %current_page% of %total_page%",
))

A Page

Reach for this when you need content and an embed on the same page, or when the page carries a file.

python
p.add_page(Page(
    content="Here is the chart:",
    embed=Message(title="October"),
    attachments=[Attachment(file=chart_bytes, filename="chart.png")],
))

Every field is optional, but a page must carry at least one of them. An empty page raises an error rather than sending a blank message.

Building pages in a loop

python
members = await get_members()
per_page = 10

for start in range(0, len(members), per_page):
    chunk = members[start:start + per_page]
    lines = "\n".join(f"• {m.mention}" for m in chunk)
    p.add_page(Message(title="Members", description=lines))

A paginator holds at most 200 pages.


Attachments

A page can carry files. Three things are accepted, and you can mix them in one page:

python
p.add_page(Page(
    content="This month's report",
    attachments=[
        Attachment(file=chart_bytes, filename="chart.png"),
        await load_file("https://example.com/report.pdf", "report.pdf"),
        "https://example.com/logo.png",
    ],
))
You passWhat it is
Attachment(file=..., filename=...)bytes you built in the script
await load_file(url, "name.ext")a file downloaded and renamed
"https://..."any http or https link

Give load_file a filename. Without one it hands back raw bytes with no name, which cannot be sent as an attachment — and Discord needs the extension to know what it is looking at.

Only http and https links are allowed. A file path is refused, on purpose — the bot would otherwise be reading files off its own disk and posting them to your channel.

Links without a file extension

Discord decides whether to show a picture or a grey download box by looking at the filename. A link like https://picsum.photos/300/200 ends in 200, which tells Discord nothing, so a perfectly good photo used to arrive as a file card.

The paginator now asks the server what it is serving and names the file accordingly, so that link becomes 200.jpg and renders as an image. You do not write anything extra.

Links that already end in .png, .jpg and so on skip the lookup entirely.

Files are loaded lazily

A file is fetched when its page is actually drawn, not when you add it. A two-hundred page paginator with a picture on every page loads one — the page being looked at.

Showing pictures? Prefer an embed

If you only want a picture on screen, an embed image is lighter than an attachment. Discord fetches it itself, so your bot never handles the bytes and no filename is involved:

python
p.add_page(Page(embed=Message(image="https://picsum.photos/300/200")))

Use attachments for files people should download. Use an embed image for pictures people should look at.


Placeholders

Two placeholders are filled in as each page is drawn:

PlaceholderBecomes
%current_page%the page number being viewed, starting at 1
%total_page%how many pages there are

They work in a page's content, and in an embed's title, description and footer.

python
p.add_page(Message(
    description="Some text",
    footer="Page %current_page% of %total_page%",
))

Your stored page keeps its placeholders, so revisiting a page renders it correctly again. There is no counter button in the middle of the row — put the count in a footer where it belongs.


The built-in buttons

Prev and Next are always shown; a paginator without them is not a paginator. The rest you switch on.

python
p = PaginatorV2(
    interaction=interaction,
    show_first=True,    # jump to page 1
    show_last=True,     # jump to the final page
    show_jump=True,     # pick a page directly
    show_stop=True,     # close the paginator (on by default)
)

They always appear in reading order: First, Prev, Next, Last, Stop.

First and Prev grey out on page 1. Next and Last grey out on the final page. You do not manage that.

Renaming them

python
p = PaginatorV2(
    interaction=interaction,
    prev_text="Back",
    next_text="Forward",
    stop_text="Close",
    first_text="Start",
    last_text="End",
    jump_text="Go to",
)

About Jump

show_jump=True only appears when there are more than 2 pages — below that Prev and Next already reach everything.

  • 25 pages or fewer → a dropdown menu listing every page, on its own row.
  • More than 25 → a Jump button that opens a small box to type a page number into. Discord menus cannot hold more than 25 options, so past that a text box is the only honest option.

Both are handled for you. You do not write anything extra.


Emojis

The buttons come with artwork already, so they look right with no setup.

python
p = PaginatorV2(
    interaction=interaction,
    prev_emoji="⬅️",
    next_emoji="➡️",
    stop_emoji="<:my_close:1234567890>",
)

Unicode emojis work as-is. A custom emoji must be the full <:name:id> form — the bare name or the id alone will be rejected by Discord as an invalid emoji.

Anything you set by hand wins, so you can replace one button and leave the others alone. auto_emojis=False turns the built-in artwork off entirely and gives you plain text buttons.

Emoji-only buttons

python
p = PaginatorV2(interaction=interaction, emoji_only=True)

Labels are dropped and the buttons shrink to squares. Useful once you have five of them and the row is eating the message width.

A button with an emoji and no label is fine. A button with neither is an error — if emoji_only=True and a button has no emoji, it keeps its label rather than rendering blank.


Custom buttons

add_button() puts your own buttons underneath the pager.

python
p.add_button("Refresh", action="reload", style="secondary")

Parameters

NameDefaultWhat it does
labelrequiredbutton text
style"secondary"primary, secondary, success, danger
action"next"a page action — see below
emojiNoneunicode, or <:name:id>
urlNonemakes it a link button
row2which row it sits on; row 1 is the pager
on_clickNoneyour own function

Page actions

action accepts: first, prev, next, last, stop, jump, reload.

Use it to build a second pager in your own style:

python
p.add_button("⏮", action="first", style="secondary", row=2)
p.add_button("⏭", action="last", style="secondary", row=2)
p.add_button("Refresh", action="reload", style="success", row=2)

Your own function

Pass on_click and the button runs your code instead. It receives the paginator and the interaction, and may be sync or async — both work, so you do not have to think about which one you wrote.

python
async def add_to_cart(pag, interaction):
    item = items[pag.current_page]
    await cart.insert({"user": interaction.user.id, "item": item["id"]})

p.add_button("Add to cart", style="success", on_click=add_to_cart)

Because you get the paginator, the callback can change what is on screen:

python
def refresh(pag, i):
    pag.pages[pag.current_page] = "Updated!"

p.add_button("Refresh", on_click=refresh)

Or move pages itself:

python
async def skip_five(pag, i):
    await pag.get_page(pag.current_page + 5)

p.add_button("Skip 5", on_click=skip_five)

The buttons are redrawn after your callback runs, so any change you make shows up straight away. Note that the current page is redrawn — edit pages[0] while somebody is on page 3 and they will see it when they get back to page 1.

If your callback raises, the person who clicked gets a private message naming the error, and the paginator stays alive:

python
raise Exception("You cannot afford that.")
# the clicker sees: Paginator error: Exception: You cannot afford that.

Link buttons

python
p.add_button("Open the docs", url="https://ccbot.app")

Discord never sends an interaction for a link button, so it costs nothing and never expires.

Rows

Row 1 is the pager. Your buttons default to row 2. Discord allows 5 rows of 5 buttons each; a Jump menu is placed on a row of its own, below everything else, because a menu cannot share a row with buttons.

A paginator takes at most 20 custom buttons.


Who can drive it

By default, only the person who ran the command. Everyone else gets a private "this paginator belongs to someone else" and the pages do not move.

python
p = PaginatorV2(interaction=interaction, public=True)

public=True lets anyone in the channel drive it — good for a leaderboard, bad for someone's private inventory.

To hand it to a specific person:

python
p = PaginatorV2(interaction=interaction, user_id=target.id)

Sessions are keyed by a 48-bit random id, so two paginators — in the same server or in different ones — can never answer each other's buttons.


Timeout

timeout is in seconds.

python
p = PaginatorV2(interaction=interaction, timeout=600)   # 10 minutes
  • Default: 180 (three minutes)
  • Allowed: 1 to 3600 (one hour)

Outside that range is an error, not a silent adjustment — asking for 5000 and quietly getting 3600 is the sort of thing you only discover with a stopwatch.

Every click pushes the timer back. It measures idle time, not total life, so a paginator someone is actively reading stays open. Clicks from people who are not allowed to drive it do not count, so a stranger cannot hold your paginator open.

When it expires the buttons are greyed out, so nobody clicks a dead paginator and gets Discord's red error.


Starting, waiting, stopping

start()

python
exit_code = await p.start()

if exit_code == "timeout":
    set_error("You took too long.")

Sends page one and waits until the pages close. Returns one of two words:

CodeMeans
"stopped"Stop was pressed, your code called stop(), or the message was deleted
"timeout"nobody clicked in time

The same two words View.wait() uses, so the check reads the same either way.

To send into a different channel instead of replying:

python
await p.start(channel=get_channel("logs"))

start(block=False) — let go early

python
await p.start(block=False)

Sends page one and carries straight on. Your command finishes, your post hook runs, and any lock you took is released, all while the pages stay live and clickable.

Reach for this when something must happen now rather than after the reading is done — releasing a lock, mostly. You can still wait later:

python
await p.start(block=False)
# ...other work...
exit_code = await p.wait()

One thing to know: with block=False, your command's own response message is not sent. It would edit the same message the pages are on and take the buttons away from whoever is reading.

stop()

python
await p.stop()

Closes the session and greys the buttons out.


What happens when the pages close

The paginator owns the message while it is open. Once it closes, your command's configured response takes the message over completely — content, embed, buttons and files. Nothing from the last page is left behind.

So the response you set up in the dashboard is what your members are left looking at, which is usually what you want. If you would rather they were left on the final page, leave the command's response empty.


Useful properties

python
p.current_page     # 0 for the first page
p.total_pages      # how many pages there are
await p.get_page(3)   # move to page 4 (indexes start at 0)

get_page() clamps out-of-range numbers to the first or last page rather than failing.


Limits

LimitValue
Pages per paginator200
Custom buttons20
Paginators per execution3
Timeout1–3600 seconds
Jump menu options25 (a text box is used beyond that)
Attachments per page10 (Discord's own limit)

When something goes wrong

A click after the session ended gets a private "this paginator has expired, run the command again". No red error.

An error while drawing a page is reported privately to the person who clicked, and the paginator survives.

A deleted message closes the session quietly — there is nothing left to page.

A link that cannot be reached still sends; it just arrives without a guessed filename.


A complete example

python
items = await shop.find({})

p = PaginatorV2(
    interaction=interaction,
    timeout=600,
    show_first=True,
    show_last=True,
    show_jump=True,
    emoji_only=True,
)

for item in items:
    p.add_page(Page(
        embed=Message(
            title=item["name"],
            description=item["description"],
            color="#5865F2",
            footer="Item %current_page% of %total_page%",
        ),
        attachments=[item["image_url"]],
    ))


async def buy(pag, i):
    item = items[pag.current_page]

    money = await wallet.find_one({"user_id": str(i.user.id)})
    if not money or money["coins"] < item["price"]:
        raise Exception("You cannot afford that.")

    await wallet.update_one(
        {"user_id": str(i.user.id)},
        {"$inc": {"coins": -item["price"]}},
    )
    await inventory.insert({"user_id": str(i.user.id), "item": item["id"]})


p.add_button("Buy", style="success", on_click=buy, row=2)
p.add_button("Full shop", url="https://ccbot.app/shop", row=2)

exit_code = await p.start()

if exit_code == "timeout":
    response = "Shop closed — you went quiet."
else:
    response = "Thanks for shopping!"

The shop stays open for ten minutes of browsing and closes itself when nobody is reading. Each item's picture is fetched only when that item is shown. The Buy button reports its own errors without taking the pages down, and the closing message is chosen from how the session ended.


Frequently asked questions

How do I make a multi-page embed in Discord?

Create a paginator, add one Message per page, then start it. Discord has no built-in pagination — the buttons and the page tracking come from the bot, which is what this does for you.

Can more than one person click the buttons?

By default no — only the member who ran the custom command. Pass public=True to let anyone in the channel page through it, or user_id=someone.id to hand it to a specific person.

How long does a Discord paginator stay open?

Three minutes of inactivity by default, and up to one hour with timeout=3600. Every click pushes the timer back, so a paginator someone is reading stays open. When it expires the buttons grey out instead of throwing Discord's red "interaction failed".

Does my custom command keep running while the pages are open?

Yes. await p.start() waits until the pages close, so the lines after it run when the reading is done. Use start(block=False) if you need your command to finish immediately — for example to release a lock — and the buttons will stay live after it ends.

Can I put images in a Discord paginator?

Yes. Give a page an attachments list holding built files, downloaded files, http links, or a mix. Links without a file extension are named from what the server actually serves, so they show as pictures rather than download boxes. For pictures alone, an embed image is lighter.

Can I add my own buttons to a Discord paginator?

Yes. add_button() takes a label, a style, an emoji and either a page action (first, prev, next, last, stop, jump, reload), a link url, or your own on_click function, which may be sync or async.

How many pages can one paginator hold?

Up to 200, with at most 20 custom buttons, and 3 paginators per command run.

Loading comments…

{}

Need help?

Have a question, a suggestion, or stuck on something? Reach out — we're happy to help.

Join support server
Custom CommandsCustom Commands

The #1 custom commands Discord bot — build commands, events and databases with zero boilerplate.

Join the support server

Product

  • Overview
  • Reaction roles
  • Welcome messages
  • Embed builder
  • Custom bot
  • Why us?
  • Premium
  • Template shop

Resources

  • Blog
  • Support
  • Dashboard

Company

  • Privacy
  • Contact
[email protected]

2 Frederick Street, London, WC1X 0ND

Payments secured by Stripe
{ / } custom commands

© WEiRDSOFT LTD. All rights reserved.