A discord modal is the cleanest way to collect structured input from a user without flooding a channel with back-and-forth messages. The ticket forms, application systems, feedback boxes and "submit a suggestion" flows you see on busy servers are almost always powered by a modal: the user clicks a button or runs a command, a small popup form opens in the center of the screen, they fill in the fields and submit. In this guide I'll walk through building a modal from scratch with discord.js, validating the fields, and handling the submitted data step by step.
What is a modal and when to use it
A modal is Discord's popup form component. It can be opened in response to an interaction such as a button, a select menu or a slash command. A modal can hold at most 5 text input fields, and each field is either single-line (Short) or multi-line (Paragraph).
- Good fit: opening a ticket, an application form, a bug report, or anything that needs free-form text such as a name or a reason.
- Bad fit: yes/no questions or a fixed set of choices — a button or a select menu is the right tool there.
One important constraint: you can only open a modal as a direct response to a user interaction. You cannot send a message first and then open a modal; the interaction.showModal() call has to be the first reply to that interaction.
Requirements and setup
This example targets discord.js v14. Make sure Node.js 18 or newer is installed. In a fresh folder:
npm init -y
npm install discord.js
I'll assume you've already created your bot application in the Discord Developer Portal, grabbed its token, and invited the bot to your server with the applications.commands scope.
Building the modal
A modal is made of four building blocks: ModalBuilder (the window itself), TextInputBuilder (each field), TextInputStyle (short/long), and an ActionRowBuilder that wraps the fields. Each text input goes into its own action row.
const {
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
} = require('discord.js');
function buildSupportModal() {
const modal = new ModalBuilder()
.setCustomId('support_modal')
.setTitle('Support Request');
const subject = new TextInputBuilder()
.setCustomId('subject')
.setLabel('Subject')
.setStyle(TextInputStyle.Short)
.setMinLength(3)
.setMaxLength(80)
.setRequired(true);
const description = new TextInputBuilder()
.setCustomId('description')
.setLabel('Describe your issue')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('Give as much detail as you can...')
.setMaxLength(1000)
.setRequired(true);
modal.addComponents(
new ActionRowBuilder().addComponents(subject),
new ActionRowBuilder().addComponents(description),
);
return modal;
}
The customId values are critical here: both the modal and every input need a unique customId, because we'll read the submitted data back using exactly these identifiers.
Opening the modal
Let's open the modal from a slash command. When the slash command interaction arrives, we call showModal. Note: if you're going to show a modal, do not call deferReply or reply first — the modal must be the interaction's very first response.
client.on('interactionCreate', async (interaction) => {
if (interaction.isChatInputCommand() && interaction.commandName === 'support') {
await interaction.showModal(buildSupportModal());
}
});
You can open the very same modal from a button too; the only difference is that you check the interaction type with interaction.isButton(). The rest of the logic stays the same.
Handling and validating the submission
When the user fills in the form and submits, a new interaction arrives. We catch it with interaction.isModalSubmit(), distinguish which modal it is via customId, and read the field values with fields.getTextInputValue().
client.on('interactionCreate', async (interaction) => {
if (!interaction.isModalSubmit()) return;
if (interaction.customId !== 'support_modal') return;
const subject = interaction.fields.getTextInputValue('subject').trim();
const description = interaction.fields.getTextInputValue('description').trim();
// Discord enforces min/max length; still add your own checks
if (subject.length < 3) {
return interaction.reply({
content: 'The subject looks too short.',
ephemeral: true,
});
}
// Process the data here: save to a DB, post to a channel, open a ticket...
await interaction.reply({
content: `Your request was received! Subject: **${subject}**`,
ephemeral: true,
});
});
Discord already enforces your setMinLength/setMaxLength and setRequired rules on the client side, but real validation — say an email's format or a number's range — is up to you. For invalid input, replying with ephemeral: true so only the user sees the warning is the cleanest approach.
Putting the data to use: a ticket example
In most projects the modal data is posted to a log channel as an embed or written to a database. For example, dropping the incoming request into a staff channel:
const { EmbedBuilder } = require('discord.js');
const logChannel = interaction.guild.channels.cache.get('LOG_CHANNEL_ID');
const embed = new EmbedBuilder()
.setTitle('New Support Request')
.addFields(
{ name: 'Subject', value: subject },
{ name: 'Description', value: description },
{ name: 'From', value: `${interaction.user}` },
)
.setTimestamp();
await logChannel.send({ embeds: [embed] });
From here you can extend the flow however you like: add button actions like "close" or "claim", persist the request to MySQL/SQLite, or forward it to an external API.
Common mistakes
- Calling reply/defer before showing the modal: this throws "Interaction has already been acknowledged". The modal must always be the first response.
- customId collisions: giving different modals the same id breaks your submit routing. Give every modal and every input a unique id.
- The 3-second rule: you also have to respond to a modal submit quickly. If you're going to do long work, call
interaction.deferReply()first, then useeditReply. - Exceeding the 5-field limit: Discord allows at most 5 inputs in a modal; if you need more, you have to split the flow.
Frequently Asked Questions
How many fields can a modal have?
At most 5 text input fields. Each field must sit inside its own ActionRowBuilder and can only be a text input; you can't use buttons or select menus inside a modal.
Can I use a dropdown (select menu) inside a modal?
No. Modals currently support text inputs only. If you need a choice, show a select menu first and open the modal after the selection, or collect the choice in a separate step outside the modal.
Why can't I read the submitted data?
Usually the id you pass into getTextInputValue() isn't identical to the input's setCustomId() value. Make sure the two match exactly.
Want a professional Discord bot for your server? I build ticket systems, application forms and modal-based flows in a clean, maintainable way. Get in touch to talk about your project.