Spraxium logoSpraxium

V2 Containers

Build richly composed Discord messages using the V2 container system. Learn the class-based schema pattern, the fluent API, dynamic children, conditional rendering, and V2Service.

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.

src/handlers/my.handler.ts
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.

src/handlers/greet.handler.ts
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

MethodReturnsPurpose
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?)ContainerBuilderSynchronous render path for containers that do not require async child resolution.
buildReplySync(ContainerClass, data?)V2ReplyPayloadSynchronous reply payload builder, useful for purely static V2 schemas.
container(opts?)V2ContainerFluentBuilderOpens a fluent builder for imperative container construction.
text(content)TextDisplayBuilderCreates a standalone text block. Accepts a string or DescriptionBuilder.
separator(config?)SeparatorBuilderCreates a separator. Pass the divider option as false for invisible spacing.
thumbnail(url, opts?)ThumbnailBuilderCreates a thumbnail accessory ready to be passed to the section builder.
section(text, accessory?)SectionBuilderCreates a section with text and an optional thumbnail or button accessory.
gallery(items)MediaGalleryBuilderCreates a media gallery from an array of media item objects with URL, optional description, and optional spoiler state.

Property decorators

DecoratorChild typeDescription
@V2Text(content)Text displayRenders a TextDisplayBuilder with the given string or DescriptionBuilder output. Accepts a factory receiving runtime data.
@V2Section(config)SectionSide-by-side layout with a text block and either a thumbnail or a button accessory. Both text and thumbnail URL accept factories.
@V2Separator(config?)SeparatorHorizontal rule. Pass the divider option as false for an invisible spacer. Use spacing values 1 or 2 to control gap size.
@V2Row(config)Action rowEmbeds 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 galleryRenders a media gallery. The items can be a static array or a factory returning media item objects.
@V2File(config)File attachmentAttaches 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 childrenCalls a factory with runtime data to produce a variable-length list of child specs. Covered in detail in the next section.
@V2When(predicate)ConditionApplied 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.

src/schemas/leaderboard.container.ts
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.

src/handlers/leaderboard.handler.ts
@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.

src/schemas/member-card.container.ts
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.

src/modules/leaderboard/components/leaderboard.container.ts
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.

src/modules/quick-actions/components/quick-actions.container.ts
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.

MethodOutput
.h2(text)

## text, renders as a heading inside the container

.subtext(text)

-# text, renders as small subtext beneath regular content

.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.

OptionTypeDefaultDescription
accentColornumber | stringNone

Left-side accent stripe color. Accepts a hex number such as 0x5865f2 or a hex string such as #5865F2.

spoilerbooleanfalse

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.

sending a V2 reply
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.

src/modules/shop/buttons/buy-button.component.ts
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.

src/modules/tickets/selects/category-select.component.ts
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:

  1. You render dynamic buttons/selects from real runtime objects (product, ticket, user, etc.).
  2. The framework stores each payload with TTL and appends ~p:<id> to the final customId.
  3. 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.).

src/modules/shop/buttons/buy-button.handler.ts
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:

  • @V2DynamicRow using dynamic: item expansion.
  • @V2Row with 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:

  1. Add await before this.v2.build(...) and this.v2.buildReply(...).
  2. Keep using buildReplySync() only for static containers without dynamic rows/selects.
  3. For list-like action UIs, migrate manual row chunking to @V2DynamicRow.
  4. For data-rich interaction handlers, replace custom ID parsing with @ButtonPayload() / @SelectPayload().
  5. For one-shot actions (purchase, confirm, consume), inject @PayloadRef() and call consume().