Modals: collect structured input

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

  • 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.
  • 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 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 cannot deferReply() or reply() 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 the isModalSubmit branch.

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 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.
  • Send answers to a staff channel. Replace the console.log with guild.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() or deferReply() before showModal(). Show the modal first, with nothing before it.
  • Nothing happens on submit — your 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).
  • 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).

Next steps

Was this guide helpful?