Only show guides for your app
Pop up a form when someone runs a command, read the answers back, and turn them into an embed — the discord.js modal pattern, with the one timing rule that catches everyone.
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 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.
A modal is two interactions: opening it, and receiving what was typed.
ModalBuilder is the form. Its customId (applyForm) is the label you'll match on when it comes back, and setTitle is the header.TextInputBuilder is one box. TextInputStyle.Short is a single line; TextInputStyle.Paragraph is a multi-line box. setMinLength/setMaxLength enforce length before submission, setPlaceholder shows grey hint text, and setRequired(true) blocks an empty submit.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 code returns, because showing the modal is the reply to the command.interaction.isModalSubmit() catches the filled-in form, and matching customId === '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.
| 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 |
isChatInputCommand check for interaction.isButton() — buttons are the other place a modal can start from. Handy for a "Create ticket" button that pops a form.console.log with guild.channels.cache.get(id).send({ embeds: [embed] }) to route applications to your team.setValue('draft text') seeds a box with editable default text.reply() or deferReply() before showModal(). Show the modal first, with nothing before it.isModalSubmit() branch is missing or the customId doesn't match. The modal's customId and the check must be the same string.getTextInputValue returns undefined — the string you passed doesn't match a TextInputBuilder's customId. They must match exactly (name, why here).