What V2 containers are
Discord's Components V2 API replaces the traditional message layout model with a composable block system. Instead of a message being a flat combination of text content, embeds, and component rows, V2 lets you combine text blocks, sections, media galleries, separators, and action rows inside a single container element. The container becomes the top-level layout unit for the message.
Spraxium's V2 system wraps this API with the same decorator-handler architecture used across the rest of @spraxium/components. You declare a container schema as a decorated class, annotate each structural element with a property decorator, and render the whole container through V2Service by passing runtime data. The result is a strongly-typed, composable layout that separates structure from the data flowing through it at render time.
The package provides two authoring styles for V2. The class-based schema pattern uses @V2Container plus property decorators and is best for reusable, data-driven layouts. The fluent API is an imperative builder that chains calls on a V2ContainerFluentBuilder instance and is best for one-off or dynamically assembled messages. Both styles produce the same wire-format and can be mixed within the same bot.
When to use V2 containers
V2 containers are appropriate whenever the message needs structured layout that a plain embed cannot express. Common use cases include leaderboards with variable-length entry lists, server dashboard panels with an icon thumbnail and stat grid, profiles with an avatar alongside multiple fields, and image galleries embedded in command responses. Any message that benefits from visual separation between sections, or that would require multiple embeds to approximate, is a good candidate for a V2 container.
V2 containers require the MessageFlags.IsComponentsV2 flag when sending messages. Setting this flag means the message cannot simultaneously carry traditional embeds or a plain content string alongside the containers, design V2 messages as self-contained layouts, not as enhancements to existing content.
Setting up V2Service
V2Service is the rendering and construction service for all V2 work. It is automatically available via injection once ComponentsModule is registered in your module graph. No additional configuration is needed beyond the standard defineComponents plugin setup.
Inject V2Service into any handler or service that needs to render V2 containers.
import { V2Service } from '@spraxium/components';
@SlashCommandHandler(MyCommand)
export class MyHandler {
constructor(private readonly v2: V2Service) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
// ...
}
}Class-based container schemas
The class-based pattern declares a container as an annotated class. Apply @V2Container to the class to mark it as a container and configure the accent color and spoiler options. Apply property decorators to each field to register child components in declaration order. At render time, V2Service.build reads the metadata, evaluates all factory functions with your runtime data, and assembles the container.
The property names in the schema class do not matter at runtime. Only the metadata registered by
the decorators is read. Declare the property type as never to signal that the field only exists
as a metadata slot.
import {
V2Container,
V2Separator,
V2Section,
V2Text,
desc,
} from '@spraxium/components';
export interface UserProfileData {
username: string;
userId: string;
joinedAt: number;
avatarUrl: string;
bio: string;
}
@V2Container({ accentColor: 0x5865f2 })
export class UserProfileContainer {
@V2Text((data: UserProfileData) => desc().h2(`👤 ${data.username}`))
title!: never;
@V2Separator()
divider!: never;
@V2Section({
text: (data: UserProfileData) =>
[
`**ID:** \`${data.userId}\``,
`**Joined:** <t:${Math.floor(data.joinedAt / 1000)}:R>`,
`**Bio:** ${data.bio}`,
].join('\n'),
thumbnail: {
url: (data: UserProfileData) => data.avatarUrl,
description: 'User avatar',
},
})
stats!: never;
@V2Separator({ divider: false })
spacer!: never;
@V2Text((data: UserProfileData) => desc().subtext(`Profile for ${data.username}`))
footer!: never;
}The fluent API
The fluent API is a chainable builder attached to V2Service. Call v2.container() to open a builder, chain child component calls, and call .toReply() at the end to produce the payload ready for interaction.reply(). Each chain method returns the same builder, so every operation can be inlined.
The fluent API does not support property-level decorators or @V2When conditions because there is no class-level metadata to annotate. Use the class-based pattern when you need conditional rendering or when you want to reuse the same layout in multiple handlers.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { V2Service, desc } from '@spraxium/components';
import { MessageFlags, type ChatInputCommandInteraction } from 'discord.js';
import { GreetCommand } from '../commands/greet.command';
@SlashCommandHandler(GreetCommand)
export class GreetHandler {
constructor(private readonly v2: V2Service) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const user = interaction.user;
const payload = this.v2
.container({ accentColor: 0x57f287 })
.text(desc().h2(`👋 Hello, ${user.username}!`))
.sep()
.section(
[
`**ID:** \`${user.id}\``,
`**Account age:** <t:${Math.floor(user.createdTimestamp / 1000)}:R>`,
].join("\n"),
this.v2.thumbnail(user.displayAvatarURL({ size: 128 }), {
description: `${user.username} avatar`,
}),
)
.sep({ divider: false })
.text(desc().subtext('Powered by Spraxium V2'))
.toReply();
await interaction.reply({
...payload,
flags: payload.flags | MessageFlags.Ephemeral,
});
}
}V2Service API reference
| Method | Returns | Purpose |
|---|---|---|
| build(ContainerClass, data?) | Promise<ContainerBuilder> | Asynchronously renders a class-based schema into a discord.js ContainerBuilder. |
| buildReply(ContainerClass, data?) | Promise<V2ReplyPayload> | Asynchronously renders and wraps in a reply-ready payload with the IsComponentsV2 flag set. |
| buildSync(ContainerClass, data?) | ContainerBuilder | Synchronous render path for containers that do not require async child resolution. |
| buildReplySync(ContainerClass, data?) | V2ReplyPayload | Synchronous reply payload builder, useful for purely static V2 schemas. |
| container(opts?) | V2ContainerFluentBuilder | Opens a fluent builder for imperative container construction. |
| text(content) | TextDisplayBuilder | Creates a standalone text block. Accepts a string or DescriptionBuilder. |
| separator(config?) | SeparatorBuilder | Creates a separator. Pass the divider option as false for invisible spacing. |
| thumbnail(url, opts?) | ThumbnailBuilder | Creates a thumbnail accessory ready to be passed to the section builder. |
| section(text, accessory?) | SectionBuilder | Creates a section with text and an optional thumbnail or button accessory. |
| gallery(items) | MediaGalleryBuilder | Creates a media gallery from an array of media item objects with URL, optional description, and optional spoiler state. |
Property decorators
| Decorator | Child type | Description |
|---|---|---|
| @V2Text(content) | Text display | Renders a TextDisplayBuilder with the given string or DescriptionBuilder output. Accepts a factory receiving runtime data. |
| @V2Section(config) | Section | Side-by-side layout with a text block and either a thumbnail or a button accessory. Both text and thumbnail URL accept factories. |
| @V2Separator(config?) | Separator | Horizontal rule. Pass the divider option as false for an invisible spacer. Use spacing values 1 or 2 to control gap size. |
| @V2Row(config) | Action row | Embeds a button row or select menu row inside the container. Pass component classes in the components array. A select menu class is auto-detected. |
| @V2MediaGallery(items) | Media gallery | Renders a media gallery. The items can be a static array or a factory returning media item objects. |
| @V2File(config) | File attachment | Attaches a file URL inside the container. The URL can be a factory. Pass the spoiler option as true to hide the preview until the user clicks. |
| @V2Dynamic(factory) | Dynamic children | Calls a factory with runtime data to produce a variable-length list of child specs. Covered in detail in the next section. |
| @V2When(predicate) | Condition | Applied after a child decorator. The child is omitted from the rendered container when the predicate returns false. |
Dynamic children with @V2Dynamic
@V2Dynamic is the solution when the number of child components depends on runtime data. Instead of a static configuration, you provide a factory function that receives the data object and returns an array of child specs created with the helper functions v2text, v2sep, v2section, v2gallery, and v2row. The engine walks this array and renders each spec in sequence.
A common pattern is to generate one or more text lines per data entry and insert separators between them, stopping one separator short so the last entry isn't followed by a divider.
import {
V2Container,
V2Dynamic,
V2Separator,
V2Text,
desc,
v2sep,
v2text,
} from '@spraxium/components';
export interface LeaderboardEntry {
username: string;
score: number;
rank: number;
}
export interface LeaderboardData {
title: string;
entries: Array<LeaderboardEntry>;
}
const MEDALS = ['🥇', '🥈', '🥉'];
@V2Container({ accentColor: 0xf1c40f })
export class LeaderboardContainer {
@V2Text((data: LeaderboardData) => desc().h2(`🆠${data.title}`))
title!: never;
@V2Separator()
divider!: never;
@V2Dynamic((data: LeaderboardData) => {
if (data.entries.length === 0) {
return [v2text(desc().subtext('No entries yet.'))];
}
return data.entries.flatMap((entry, index) => {
const medal = MEDALS[index] ?? `**${entry.rank}.**`;
return [
v2text(
`${medal} **${entry.username}**, ${entry.score.toLocaleString()} pts`,
),
...(index < data.entries.length - 1 ? [v2sep({ divider: false })] : []),
];
});
})
entries!: never;
@V2Separator({ divider: false })
footer!: never;
@V2Text((data: LeaderboardData) =>
desc().subtext(`${data.entries.length} players ranked`),
)
summary!: never;
}The handler passes the entry list as data and calls buildReply without knowing how many rows will be generated.
@SlashCommandHandler(LeaderboardCommand)
export class LeaderboardHandler {
constructor(private readonly v2: V2Service) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const entries: Array<LeaderboardEntry> = await fetchTopPlayers(10);
const payload = await this.v2.buildReply(LeaderboardContainer, {
title: 'Weekly Top Players',
entries,
});
await interaction.reply(payload);
}
}Conditional rendering with @V2When
@V2When conditionally includes a child based on runtime data. It is applied as an additional
decorator below the child decorator, which means it runs last and patches the existing child
definition with a guard predicate. If the predicate returns false for the given data, the child
is excluded from the container entirely, not rendered as empty.
@V2When is stackable: multiple children in the same container can each carry independent conditions. The order property assigned at registration time is preserved regardless of which children are filtered out.
import {
V2Container,
V2Separator,
V2Text,
V2When,
desc,
} from '@spraxium/components';
export interface MemberCardData {
username: string;
boostLevel: number;
isAdmin: boolean;
bio: string;
}
@V2Container({ accentColor: 0x5865f2 })
export class MemberCardContainer {
@V2Text((data: MemberCardData) => desc().h2(data.username))
title!: never;
@V2Separator()
divider!: never;
@V2When((data: MemberCardData) => data.isAdmin)
@V2Text('âš™ï¸ **Server Administrator**')
adminBadge!: never;
@V2When((data: MemberCardData) => data.boostLevel > 0)
@V2Text(
(data: MemberCardData) => `â **Server Booster**, Level ${data.boostLevel}`,
)
boostBadge!: never;
@V2Text((data: MemberCardData) => data.bio)
bio!: never;
}When isAdmin is false, the admin badge row is not rendered at all. The remaining children close
up naturally without any gap.
Embedding action rows with @V2Row
@V2Row places a button row or select menu row directly inside a V2 container. Pass your component
classes in the components array the same way you would pass them to ButtonService or
SelectService. The service auto-detects whether the first class is a select menu and routes to
the correct builder automatically.
import {
Button,
LinkButton,
V2Container,
V2Row,
V2Separator,
V2Text,
desc,
} from '@spraxium/components';
@Button({ customId: 'panel_confirm', label: 'Confirm', style: 'success', emoji: '✅' })
export class ConfirmButton {}
@Button({ customId: 'panel_cancel', label: 'Cancel', style: 'danger', emoji: '✖' })
export class CancelButton {}
@V2Container({ accentColor: 0xed4245 })
export class ConfirmPanelContainer {
@V2Text((data: { action: string }) => desc().h2(`Confirm: ${data.action}`))
title!: never;
@V2Text('This action cannot be undone. Are you sure?')
body!: never;
@V2Separator({ divider: false })
spacer!: never;
@V2Row({ components: [ConfirmButton, CancelButton] })
actions!: never;
}@V2DynamicRow
@V2DynamicRow auto-chunks a runtime list of items or button classes into rows of up to five inside a container. It is the V2 equivalent of calling ButtonService.build multiple times, but driven by data rather than a static class list.
Two modes are available:
Dynamic mode: pair a @DynamicButton class with a data accessor that produces the item list. Spraxium calls the button's render(item) static method for each item, stores each item as a payload, and groups the resulting buttons into rows of five. This is the main use case: one button class, N items, N handled buttons per interaction.
import { V2Container, V2Separator, V2Text, V2DynamicRow, desc } from '@spraxium/components';
import type { LeaderboardData } from '../leaderboard.data';
import { PlayerButton } from './player-button.component';
@V2Container({ accentColor: 0x5865f2 })
export class LeaderboardContainer {
@V2Text((data: LeaderboardData) => desc().h2(`🆠${data.title}`))
title!: never;
@V2Separator()
divider!: never;
@V2DynamicRow({ dynamic: PlayerButton, items: (data: LeaderboardData) => data.players })
playerButtons!: never;
}Components mode: provide a factory function that returns an array of static button classes. Spraxium builds all returned classes into rows of five, with no payloads involved. Use this when the set of buttons is variable but each button has a fixed customId.
import { V2Container, V2DynamicRow, V2Row, V2Text, desc } from '@spraxium/components';
import type { QuickActionsData } from '../quick-actions.data';
import {
ArchiveButton,
DeleteButton,
ExportButton,
MuteButton,
PinButton,
RefreshButton,
ShareButton,
} from './quick-actions.components';
import { ModeSelect } from './quick-actions.components';
@V2Container({ accentColor: 0x57f287 })
export class QuickActionsContainer {
@V2Text((data: QuickActionsData) => desc().h2(`âš¡ Quick actions for ${data.resource}`))
title!: never;
@V2DynamicRow({
components: () => [
RefreshButton,
ExportButton,
ShareButton,
ArchiveButton,
DeleteButton,
PinButton,
MuteButton,
],
})
actions!: never;
@V2Row({ components: [ModeSelect] })
mode!: never;
}@V2DynamicRow with dynamic: only works with @DynamicButton classes (not static buttons). For static button chunking, use the components: factory form.
The DescriptionBuilder
The desc() helper function returns a DescriptionBuilder that chains Discord-flavoured Markdown helpers. It is available in both class-based and fluent patterns anywhere a text content value is accepted. The builder is a thin string accumulator; calling .toString() returns the final string, which the V2 engine calls automatically before passing content to the discord.js builders.
| Method | Output |
|---|---|
| .h2(text) |
|
| .subtext(text) |
|
| .bold(text) | Outputs bold markdown text. |
| .italic(text) | Outputs italic markdown text. |
| .code(text) | Outputs inline code markdown text. |
| .line(text?) | Appends text followed by a newline |
Container configuration
The @V2Container decorator accepts a V2ContainerMeta object.
| Option | Type | Default | Description |
|---|---|---|---|
| accentColor | number | string | None | Left-side accent stripe color. Accepts a hex number such as 0x5865f2 or a hex string such as #5865F2. |
| spoiler | boolean | false | Marks the entire container as a spoiler that the user must click to reveal. |
Building the reply payload
Both buildReply and the fluent .toReply() method return a { components, flags } object.
Spreading this into interaction.reply() sets IsComponentsV2 automatically. You can merge
additional flags using bitwise OR.
const payload = await this.v2.buildReply(MyContainer, data);
await interaction.reply({
...payload,
flags: payload.flags | MessageFlags.Ephemeral,
});If you need to update an existing V2 message, use interaction.editReply() or pass the payload to message.edit() directly. The flag is required on both the initial send and subsequent edits, because Discord identifies a message as a V2 message by the presence of that flag.
dynamic component system and migration notes
Spraxium now expands the V2 story beyond layout composition and introduces a complete dynamic interaction model for buttons and selects. The core idea is simple: render-time data now travels with each interactive element through a payload reference, so handlers can receive typed domain objects directly instead of re-querying state from scratch or encoding large values manually into custom IDs.
This model is especially useful for catalog UIs, dashboards, moderation queues, and any flow where you render a variable list of actions and need each action to carry rich context.
Dynamic buttons and typed payload injection
Use @DynamicButton to declare how each runtime item should be rendered into a button and
@DynamicButtonHandler to process interactions. Inject the original payload via @ButtonPayload()
and, when needed, inject a payload handle via @PayloadRef() to consume one-shot actions.
import {
ButtonPayload,
DynamicButton,
DynamicButtonHandler,
PayloadRef,
type PayloadHandle,
} from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { ButtonInteraction } from 'discord.js';
interface Product {
id: string;
name: string;
price: number;
}
@DynamicButton({ baseId: 'shop-buy', payloadTtl: 300 })
export class BuyButton {
static render(product: Product) {
return {
label: `Buy ${product.name}`,
style: 'success' as const,
emoji: '🛒',
};
}
}
@DynamicButtonHandler(BuyButton)
export class BuyButtonHandler {
async handle(
@Ctx() interaction: ButtonInteraction,
@ButtonPayload() product: Product,
@PayloadRef() payload: PayloadHandle,
): Promise<void> {
// One-shot purchase flow: prevent the same payload from being reused.
await payload.consume();
await interaction.reply({
content: `Purchase confirmed: ${product.name} ($${product.price}).`,
flags: 'Ephemeral',
});
}
}Dynamic selects with data-bound option sets
For dynamic selects, pair @DynamicStringSelect with @DynamicSelectHandler. The select can be
rendered from runtime data, while the handler receives both selected values (@SelectedValues()) and
the original payload (@SelectPayload()). This keeps presentation and domain data strongly aligned.
import {
DynamicStringSelect,
DynamicSelectHandler,
SelectPayload,
SelectedValues,
} from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { StringSelectMenuInteraction } from 'discord.js';
interface TicketCategory {
id: string;
name: string;
}
@DynamicStringSelect({ baseId: 'ticket-category', placeholder: 'Choose a category', payloadTtl: 300 })
export class TicketCategorySelect {
static render(categories: TicketCategory[]) {
return {
options: categories.map((category) => ({
label: category.name,
value: category.id,
})),
minValues: 1,
maxValues: 1,
};
}
}
@DynamicSelectHandler(TicketCategorySelect)
export class TicketCategorySelectHandler {
async handle(
@Ctx() interaction: StringSelectMenuInteraction,
@SelectedValues() values: string[],
@SelectPayload() categories: TicketCategory[],
): Promise<void> {
const selected = categories.find((category) => category.id === values[0]);
await interaction.update({
content: `Ticket category selected: ${selected?.name ?? 'Unknown'}`,
components: [],
});
}
}What payload and payload reference actually mean
In the dynamic interaction model, the payload is the original domain data used to render
the interactive element. Instead of serializing full data into customId, Spraxium stores that
data in its context-backed payload store and embeds only a short reference in the form ~p:<id>.
Practically:
- You render dynamic buttons/selects from real runtime objects (product, ticket, user, etc.).
- The framework stores each payload with TTL and appends
~p:<id>to the finalcustomId. - At interaction time, the dispatcher resolves that ID and injects the typed object into the handler.
This removes brittle string parsing and keeps customId compact.
@ButtonPayload vs @SelectPayload vs @PayloadRef
@ButtonPayload() and @SelectPayload() inject the domain object bound to the interaction.
@PayloadRef() injects a lifecycle handle (PayloadHandle) with:
id: the raw payload identifier.consume(): deletes that payload from storage to prevent reuse.
Use @PayloadRef() for one-shot actions (purchase, irreversible confirmation, coupon redemption, etc.).
import { ButtonPayload, DynamicButtonHandler, PayloadRef, type PayloadHandle } from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { ButtonInteraction } from 'discord.js';
import { BuyButton } from './buy-button.component';
interface Product {
id: string;
name: string;
}
@DynamicButtonHandler(BuyButton)
export class BuyButtonHandler {
async handle(
@Ctx() interaction: ButtonInteraction,
@ButtonPayload() product: Product,
@PayloadRef() ref: PayloadHandle,
): Promise<void> {
await this.auditPurchaseAttempt(ref.id, product.id);
// One-shot: future clicks with this payload return as expired.
await ref.consume();
await interaction.reply({
content: `Purchase confirmed: ${product.name}`,
flags: 'Ephemeral',
});
}
private async auditPurchaseAttempt(payloadId: string, productId: string): Promise<void> {
void payloadId;
void productId;
}
}TTL, expiration, and when handlers stop running
Each dynamic payload has a TTL. You can set it with payloadTtl on the dynamic component class.
If omitted, the payload service uses a default TTL of 600 seconds.
When a payload expires (or after you call consume()), later interactions carrying the same
customId do not reach your handler. The user receives an expired-action response, which you can
customize via component error message configuration.
Payload reference (~p) versus flow context (~ctx)
Both tags can coexist on the same customId, but they serve different jobs:
~p:<id>: points to dynamic component domain payload.~ctx:<id>: points to flow state (wizard progress, user restriction, step context).
Rule of thumb: payload identifies which item the action is about; flow context identifies which conversation state the interaction belongs to.
Async versus sync render path in modelo atual
In the current feature set, build() and buildReply() are asynchronous by design because V2 schemas can now include
children that require async resolution, such as:
@V2DynamicRowusingdynamic:item expansion.@V2Rowwith dynamic selects that resolve options asynchronously.- Any child that delegates to async component factories.
Use buildSync() or buildReplySync() only when your container is fully synchronous. If a schema
contains async-only children, the sync path throws intentionally to prevent silently incorrect layouts.
Practical migration checklist
When upgrading legacy V2 handlers, this quick checklist covers almost all breakages:
- Add
awaitbeforethis.v2.build(...)andthis.v2.buildReply(...). - Keep using
buildReplySync()only for static containers without dynamic rows/selects. - For list-like action UIs, migrate manual row chunking to
@V2DynamicRow. - For data-rich interaction handlers, replace custom ID parsing with
@ButtonPayload()/@SelectPayload(). - For one-shot actions (purchase, confirm, consume), inject
@PayloadRef()and callconsume().