A modal is the pop-up form Discord shows over the chat — text boxes a user fills in and submits. It's the right tool whenever you need more than a slash-command option can carry: an application, a bug report, a suggestion. This recipe opens a two-field application form with /apply and turns the answers into an embed.
| At a glance | |
|---|---|
| You need | a working bot from Host a discord.js bot |
| Plan | Free or premium |
| Time | About twenty minutes |
Builds on the discord.js bot guide and the buttons idea from Slash commands in depth.
The code
The complete index.js. /apply opens the modal; the submit is handled in the same InteractionCreate:
require('dotenv').config();
const {
Client, Events, GatewayIntentBits,
SlashCommandBuilder, EmbedBuilder,
ModalBuilder, TextInputBuilder, TextInputStyle,
ActionRowBuilder,
} = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const apply = new SlashCommandBuilder()
.setName('apply')
.setDescription('Open the staff application form');
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([apply]);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
// 1. Command opens the modal
if (interaction.isChatInputCommand() && interaction.commandName === 'apply') {
const modal = new ModalBuilder()
.setCustomId('applyForm')
.setTitle('Staff Application');
const name = new TextInputBuilder()
.setCustomId('name')
.setLabel('What should we call you?')
.setStyle(TextInputStyle.Short)
.setMaxLength(50)
.setRequired(true);
const why = new TextInputBuilder()
.setCustomId('why')
.setLabel('Why do you want to join the team?')
.setStyle(TextInputStyle.Paragraph)
.setMinLength(30)
.setMaxLength(1000)
.setPlaceholder('Tell us a bit about yourself...')
.setRequired(true);
modal.addComponents(
new ActionRowBuilder().addComponents(name),
new ActionRowBuilder().addComponents(why),
);
await interaction.showModal(modal);
return;
}
// 2. Handle the submitted form
if (interaction.isModalSubmit() && interaction.customId === 'applyForm') {
const name = interaction.fields.getTextInputValue('name');
const why = interaction.fields.getTextInputValue('why');
const embed = new EmbedBuilder()
.setColor(0xfee75c)
.setTitle('📨 New application')
.addFields(
{ name: 'From', value: `${interaction.user} (${name})` },
{ name: 'Why', value: why },
)
.setTimestamp();
await interaction.reply({ content: 'Thanks — your application was submitted!', ephemeral: true });
// In a real bot you'd send `embed` to a staff channel here.
console.log(embed.data.title);
}
});
client.login(process.env.DISCORD_TOKEN);
Paste into index.js and restart.
The parts that matter
A modal is two interactions: opening it, and receiving what was typed.
ModalBuilderis the form. ItscustomId(applyForm) is the label you'll match on when it comes back, andsetTitleis the header.TextInputBuilderis one box.TextInputStyle.Shortis a single line;TextInputStyle.Paragraphis a multi-line box.setMinLength/setMaxLengthenforce length before submission,setPlaceholdershows grey hint text, andsetRequired(true)blocks an empty submit.- Each text input goes in its own
ActionRowBuilder. Unlike buttons, you can't put two inputs in one row — one input per row, up to five rows. interaction.showModal(modal)displays the form. Then the codereturns, because showing the modal is the reply to the command.interaction.isModalSubmit()catches the filled-in form, and matchingcustomId === 'applyForm'confirms it's this modal.interaction.fields.getTextInputValue('name')reads a box by its input's customId.
⚠️ Heads up — the timing rule everyone hits:
showModal()must be the very first response to an interaction. You cannotdeferReply()orreply()and then show a modal — Discord rejects it. That also means a modal can only be opened straight from a command or a button click, never after slow work. Do the slow part after the user submits, in theisModalSubmitbranch.
The limits
| Part | Limit |
|---|---|
| Text inputs per modal | 5 (one per row) |
| Modal title | 45 characters |
| Input label | 45 characters |
| Input value | up to 4000 characters |
| Placeholder | 100 characters |
| customId (modal and inputs) | 100 characters |
Making it yours
- Open a modal from a button. Swap the
isChatInputCommandcheck forinteraction.isButton()— buttons are the other place a modal can start from. Handy for a "Create ticket" button that pops a form. - Send answers to a staff channel. Replace the
console.logwithguild.channels.cache.get(id).send({ embeds: [embed] })to route applications to your team. - Validate beyond length. After reading a value, check its shape (a number, a URL) and reply with an ephemeral error if it's wrong, so the user can try again.
- Pre-fill a value.
setValue('draft text')seeds a box with editable default text.
Troubleshooting
- "This interaction failed" when the command runs — you called
reply()ordeferReply()beforeshowModal(). Show the modal first, with nothing before it. - Nothing happens on submit — your
isModalSubmit()branch is missing or thecustomIddoesn't match. The modal'scustomIdand the check must be the same string. getTextInputValuereturns undefined — the string you passed doesn't match aTextInputBuilder'scustomId. They must match exactly (name,whyhere).- The modal won't open with more than five boxes — five inputs is the hard limit; split a longer form into two steps (a button that opens a second modal).