@nexcord/tsx
v0.0.1
Published
<div align="center">
Readme
@nexcord/tsx
Write discord.js message components — buttons, selects, modals, embeds, and Components V2 layouts — as JSX.
Part of the Nexcord ecosystem.
@nexcord/tsx is a JSX runtime for discord.js. Instead of chaining new ButtonBuilder().setCustomId(...).setLabel(...), you write:
<button id="confirm_yes" label="Confirm" style="success" action={ConfirmAction} />…and it compiles directly to the real ButtonBuilder instance discord.js expects — no virtual DOM, no diffing, just JSX as syntax sugar over the builder API you already know. Interactive elements (buttons, selects, modals) also wire themselves up to Nexcord's dependency injection cache, so handling the resulting interaction is a matter of pointing action at a class.
Table of Contents
- Requirements
- Installation
- Quick Start
- How It Works
- Element Reference
- The
actionProp & Controller Contract - Example: A Modal with Field Extraction
- Example: A Components V2 Layout
- Custom / Function Components
- Notes & Current Limitations
- License
Requirements
@nexcord/core— action controllers are resolved through its decorator cache, and interaction listeners are attached to its underlying discord.js client.discord.js^14.26.4or later — this is the release line that introduced the modalLabelfield system (LabelBuilder,CheckboxBuilder,CheckboxGroupBuilder,RadioGroupBuilder,FileUploadBuilder) and Components V2 (ContainerBuilder,SectionBuilder, etc.), both of which@nexcord/tsxtargets directly.- TypeScript, with the JSX configuration described below.
Installation
bun add @nexcord/tsx @nexcord/core discord.jsnpm install @nexcord/tsx @nexcord/core discord.js
pnpm add @nexcord/tsx @nexcord/core discord.js
yarn add @nexcord/tsx @nexcord/core discord.jstsconfig.json
@nexcord/tsx uses TypeScript's classic JSX transform with a custom pragma, so your tsconfig.json needs the following, exactly:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react",
"jsxFactory": "DiscordJSX.createElement",
"jsxFragmentFactory": "DiscordJSX.Fragment"
}
}Because the classic transform doesn't auto-import a pragma, every .tsx file that uses JSX must import DiscordJSX itself, even if you never reference the name directly — TypeScript needs it in scope to compile <tag /> into DiscordJSX.createElement('tag', ...):
import { DiscordJSX } from '@nexcord/tsx';Quick Start
Define a component that returns JSX:
// src/components/confirm.components.tsx
import { DiscordJSX } from '@nexcord/tsx';
import { ConfirmAction } from '../common/actions/Confirm.action';
export function ConfirmButtons() {
return (
<row>
<button id="confirm_yes" label="Confirm" style="success" action={ConfirmAction} />
<button id="confirm_no" label="Cancel" style="danger" action={ConfirmAction} />
</row>
);
}Handle the click with a controller class — a regular @Listener() with an execute method:
// src/common/actions/Confirm.action.ts
import { Listener } from '@nexcord/core';
import type { ButtonInteraction } from 'discord.js';
@Listener()
export class ConfirmAction {
async execute(interaction: ButtonInteraction): Promise<void> {
const confirmed = interaction.customId === 'confirm_yes';
await interaction.update({
content: confirmed ? '✅ Confirmed.' : '❌ Cancelled.',
components: []
});
}
}And send it like any other discord.js component array:
await interaction.reply({ content: 'Are you sure?', components: [ConfirmButtons()] });How It Works
@nexcord/tsx exports a single pragma object, DiscordJSX, with two functions: createElement and Fragment. When TypeScript compiles your JSX, it calls these directly — there's no build-time codegen beyond what tsc (or Bun) already does.
- Intrinsic elements (lowercase tags like
<button>,<embed>,<container>) are matched against a bigswitchand turned into the matching discord.js builder instance, fully configured from your props and children. - Function components (components you write, like
ConfirmButtonsabove) simply receive{ ...props, children }as their only argument — this is plain JSX composition, nothing Discord-specific. - Interactive elements —
<button>, the five select menus, and<modal>— additionally register a globalinteractionCreatelistener onNexCord.clientthe moment they're built, matching on the element'sidand delegating to whatever you passed asaction.
There is no virtual DOM and no re-rendering: calling a component function produces a builder tree once, synchronously.
Element Reference
Interactive components (messages)
| Element | Produces | Key props |
| --- | --- | --- |
| <row> | ActionRowBuilder | wraps buttons or a single select menu |
| <button> | ButtonBuilder | id, label, style? (primary | secondary | success | danger | link), disabled?, url?, emoji?, action? |
| <stringSelect> | StringSelectMenuBuilder | id, placeholder?, min?, max?, disable?, required?, action? — children: <option> |
| <userSelect> / <roleSelect> / <mentionableSelect> | respective *SelectMenuBuilder | id, placeholder?, min?, max?, disable?, required?, action? |
| <channelSelect> | ChannelSelectMenuBuilder | as above, plus types? (ChannelType[]) and defaults? |
| <option> | plain option object | label, value, description? — used inside selects, <checkboxGroup>, and <radioGroup> |
Modal & form field components
| Element | Produces | Notes |
| --- | --- | --- |
| <modal> | ModalBuilder | id, title, action? — children: <label> (recommended), <row> (legacy text input), <text> |
| <label> | LabelBuilder | label, description? — wraps exactly one field element below with a heading |
| <input> | TextInputBuilder | id, label?, style?, min?, max?, placeholder?, required? |
| <checkbox> | CheckboxBuilder | id, default? |
| <checkboxGroup> | CheckboxGroupBuilder | id, min?, max?, required? — children: <option> |
| <radioGroup> | RadioGroupBuilder | id, required? — children: <option> |
| <fileUpload> | FileUploadBuilder | id, min?, max?, required? |
Components V2 / display components
Discord's newer layout system for messages — see Example: A Components V2 Layout for the message-flag requirement.
| Element | Produces | Notes |
| --- | --- | --- |
| <container> | ContainerBuilder | accentColor?, spoiler? — children: <row>, <text>, <split>, <section>, <file> |
| <section> | SectionBuilder | children: one or more <text>, plus one <button> or <thumbnail> as an accessory |
| <text> | TextDisplayBuilder | plain markdown content, taken from children |
| <split> | SeparatorBuilder | divider?, spacing? |
| <thumbnail> | ThumbnailBuilder | url, description?, spoiler? |
| <file> | FileBuilder | url, spoiler? |
Embeds
| Element | Produces | Notes |
| --- | --- | --- |
| <embed> | EmbedBuilder | title, description, color? (defaults to a random color), author?, footer?, thumbnail?, image?, url?, timestamp? — children: <field> |
| <field> | embed field object | name, inline? — children become the field's value text |
The action Prop & Controller Contract
Any <button>, select menu, or <modal> accepts an action prop — a single class, or an array of classes. Each one must be decorated with @Listener() from @nexcord/core (the same registration used for event listeners) and expose an execute(...) method. When the matching interaction comes in, @nexcord/tsx resolves the class from Nexcord's decorator cache and invokes it for you:
| Triggering element | execute is called as |
| --- | --- |
| <button>, any select menu | execute(interaction, properties) |
| <modal> | execute(interaction, inputs, properties) |
propertiesis the element's own JSX props (id,label,action, …).inputs, modal-only, is a plain object keyed by each field'sid, already parsed by type: select menus resolve to their selected values, checkboxes to booleans, text inputs to strings, file uploads to the uploaded files, and role/user/channel/mentionable selects to their resolved Discord objects.- If
actionis an array, each controller'sexecuteruns in order, one after another.
Example: A Modal with Field Extraction
// src/components/support.components.tsx
import { DiscordJSX } from '@nexcord/tsx';
import { TextInputStyle } from 'discord.js';
import { SupportAction } from '../common/actions/Support.action';
export function SupportModal() {
return (
<modal id="support_modal" title="Contact Support" action={SupportAction}>
<label label="What do you need help with?" description="Pick the closest match">
<stringSelect id="topic">
<option label="Billing" value="billing" />
<option label="Bug report" value="bug" />
<option label="Something else" value="other" />
</stringSelect>
</label>
<label label="Details">
<input id="details" style={TextInputStyle.Paragraph} min={10} max={1000} />
</label>
</modal>
);
}// src/common/actions/Support.action.ts
import { Listener } from '@nexcord/core';
import type { ModalSubmitInteraction } from 'discord.js';
@Listener()
export class SupportAction {
async execute(interaction: ModalSubmitInteraction, inputs: Record<string, any>): Promise<void> {
const [topic] = inputs.topic as string[];
const details = inputs.details as string;
await interaction.reply({ content: `Thanks! Logged a **${topic}** request.`, ephemeral: true });
}
}Example: A Components V2 Layout
// src/components/status.components.tsx
import { DiscordJSX } from '@nexcord/tsx';
import { SeparatorSpacingSize } from 'discord.js';
export function StatusCard(props: { online: boolean; latency: number }) {
return (
<container accentColor={props.online ? 0x2ecc71 : 0xe74c3c}>
<section>
<text>**Bot Status**</text>
<text>{props.online ? `🟢 Online — ${props.latency}ms` : '🔴 Offline'}</text>
<thumbnail url="https://example.com/bot-avatar.png" />
</section>
<split divider spacing={SeparatorSpacingSize.Small} />
<text>Data refreshes every 60 seconds.</text>
</container>
);
}Important: Components V2 elements (
<container>,<section>,<text>,<split>,<thumbnail>,<file>) require the message to be sent with Discord'sIS_COMPONENTS_V2flag —@nexcord/tsxbuilds the components, but you still send the message:import { MessageFlags } from 'discord.js'; import { StatusCard } from '../components/status.components'; await interaction.reply({ components: [StatusCard({ online: true, latency: 42 })], flags: MessageFlags.IsComponentsV2 });Once a message is sent with this flag, its
contentandembedsfields stop working in favor of<text>and<container>— this is a Discord API rule, not a@nexcord/tsxlimitation.
Custom / Function Components
Any function can be used as a JSX element. It receives its props plus a children array (flattened one level deep) as a single object argument, and can return one element or an array of them — exactly like composing components in React:
function Field({ name, value }: { name: string; value: string }) {
return <field name={name}>{value}</field>;
}
<embed title="Profile" description="…">
<Field name="Level" value="12" />
<Field name="XP" value="4,210" />
</embed>Notes & Current Limitations
- Select menu listeners aren't de-duplicated yet.
<button>and<modal>track theiridinternally and only ever register oneinteractionCreatelistener per id, no matter how many times the element gets rebuilt. The five select-menu elements don't currently perform that check — reconstructing the same select (sameid) repeatedly, e.g. inside a command that runs often, adds another listener each time. Where you can, build selects once and reuse the reference. - This is beta software, tracking
@nexcord/core's beta status — expect the API to tighten up before1.0.
License
Released under the MIT License.
Part of the Nexcord ecosystem — see @nexcord/core and @nexcord/config · Built by SignorMassimo
