What you need to build an application system in Discord.js V14
An application system in Discord.js V14 uses modal forms and buttons to let users submit information through Discord itself, rather than linking to an external website. When someone clicks a button, a modal pops up with text fields they fill in. Your bot then receives that data and stores it, logs it, or sends it to a channel for review.
The core pieces are: a button that triggers the modal, the modal itself with input fields, and a handler that processes what the user submitted. Discord.js V14 changed how these work compared to earlier versions, so code from older tutorials will not run without updates.
This guide assumes you have a Discord bot already set up in Discord.js V14 with a command handler. If you do not, start by installing Discord.js version 14 or later with npm install discord.js and creating a basic bot file that logs in with your token.
Key Takeaways
- Buttons and modals in V14 use ButtonBuilder and ModalBuilder classes, not the older MessageButton syntax.
- A modal needs a custom ID that matches the interaction handler, so the bot knows which modal was submitted.
- Text input fields inside a modal use TextInputBuilder and must be wrapped in an ActionRowBuilder.
- You handle modal submissions with an interactionCreate listener that checks if the interaction is a modal submit, then reads the values from each field.
- Store submitted data in a database, send it to a logging channel, or both — the bot receives the raw data and you decide what to do with it.
Creating the button that opens the modal
Start by building a button that users will click. In your command file, import ButtonBuilder and ActionRowBuilder from Discord.js, then create the button with a custom ID and label.
The custom ID is a string you choose — something like apply_button or submit_form. This ID is how your bot recognizes which button was clicked. Keep it under 100 characters and use only letters, numbers, underscores, and hyphens.
Here is a minimal command that sends a button:
const { SlashCommandBuilder, ButtonBuilder, ActionRowBuilder, ButtonStyle } = require('discord.js'); module.exports = { data: new SlashCommandBuilder() .setName('apply') .setDescription('Open the application form'), async execute(interaction) { const button = new ButtonBuilder() .setCustomId('apply_button') .setLabel('Submit Application') .setStyle(ButtonStyle.Primary); const row = new ActionRowBuilder().addComponents(button); await interaction.reply({ components: [row] }); }, };
When you run /apply, the bot sends a message with a blue button. Clicking it does nothing yet — you need to create the modal next.
Building the modal with text input fields
A modal is a popup form with one or more text input fields. Import ModalBuilder and TextInputBuilder, then create the modal and add fields to it.
Each text input needs its own custom ID (different from the button ID), a label that users see, and a style — either Short for one line or Paragraph for multiple lines. Wrap all inputs in an ActionRowBuilder before adding them to the modal.
Here is a modal with three fields:
const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); const modal = new ModalBuilder() .setCustomId('apply_modal') .setTitle('Application Form'); const nameInput = new TextInputBuilder() .setCustomId('name_input') .setLabel('Your Name') .setStyle(TextInputStyle.Short) .setRequired(true); const reasonInput = new TextInputBuilder() .setCustomId('reason_input') .setLabel('Why do you want to join?') .setStyle(TextInputStyle.Paragraph) .setRequired(true); const experienceInput = new TextInputBuilder() .setCustomId('experience_input') .setLabel('Relevant Experience') .setStyle(TextInputStyle.Paragraph) .setRequired(false); const row1 = new ActionRowBuilder().addComponents(nameInput); const row2 = new ActionRowBuilder().addComponents(reasonInput); const row3 = new ActionRowBuilder().addComponents(experienceInput); modal.addComponents(row1, row2, row3);
Each field goes in its own ActionRowBuilder — you cannot put multiple text inputs in one row. The setRequired() method controls whether Discord forces the user to fill that field before submitting.
Showing the modal when the button is clicked
Now connect the button to the modal. In your main bot file, add an interactionCreate listener that checks if the interaction is a button click with the custom ID apply_button. If it matches, show the modal.
Here is the listener:
client.on('interactionCreate', async (interaction) => { if (interaction.isButton()) { if (interaction.customId === 'apply_button') { const modal = new ModalBuilder() .setCustomId('apply_modal') .setTitle('Application Form'); const nameInput = new TextInputBuilder() .setCustomId('name_input') .setLabel('Your Name') .setStyle(TextInputStyle.Short) .setRequired(true); const row = new ActionRowBuilder().addComponents(nameInput); modal.addComponents(row); await interaction.showModal(modal); } } });
When a user clicks the button, interaction.showModal() displays the form. The modal appears as a popup in their Discord client.
Handling the submitted form data
When the user fills in the modal and clicks Submit, Discord sends another interactionCreate` event. This time, interaction.isModalSubmit() returns true. Read the values from each field using the custom IDs you set earlier.
Here is how to capture and log the data:
client.on('interactionCreate', async (interaction) => { if (interaction.isModalSubmit()) { if (interaction.customId === 'apply_modal') { const name = interaction.fields.getTextInputValue('name_input'); const reason = interaction.fields.getTextInputValue('reason_input'); const experience = interaction.fields.getTextInputValue('experience_input'); console.log(`Application from ${name}: ${reason}`); await interaction.reply({ content: 'Your application was received. Thank you!', ephemeral: true }); } } });
The getTextInputValue() method retrieves what the user typed in each field. The ephemeral: true option makes the bot's reply visible only to that user, so other people in the channel do not see it.
Sending applications to a logging channel
Instead of just logging to the console, send each application to a dedicated channel where moderators can review them. Use interaction.guild.channels.cache.get() to find the channel by ID, then send an embed with the application details.
First, get your logging channel's ID by right-clicking the channel in Discord and selecting "Copy Channel ID". Then add this to your modal submit handler:
if (interaction.isModalSubmit()) { if (interaction.customId === 'apply_modal') { const name = interaction.fields.getTextInputValue('name_input'); const reason = interaction.fields.getTextInputValue('reason_input'); const experience = interaction.fields.getTextInputValue('experience_input'); const logChannel = interaction.guild.channels.cache.get('YOUR_CHANNEL_ID'); const embed = new EmbedBuilder() .setColor(0x0099ff) .setTitle('New Application') .addFields( { name: 'Name', value: name }, { name: 'Why Join', value: reason }, { name: 'Experience', value: experience || 'Not provided' }, { name: 'Submitted By', value: interaction.user.tag } ); await logChannel.send({ embeds: [embed] }); await interaction.reply({ content: 'Your application was received.', ephemeral: true }); } }
Replace YOUR_CHANNEL_ID with the actual ID. The embed formats the data nicely and includes the user's Discord tag so you know who submitted it. Import EmbedBuilder from Discord.js at the top of your file.
Organizing your code with separate files
As your application system grows, keeping everything in one file becomes messy. Split the button handler and modal handler into separate files in an events folder.
Create events/interactionCreate.js and move all your button and modal logic there. Then in your main bot file, load it with:
const fs = require('fs'); const eventFiles = fs.readdirSync('./events').filter(file => file.endsWith('.js')); for (const file of eventFiles) { const event = require(`./events/${file}`); if (event.once) { client.once(event.name, (...args) => event.execute(...args)); } else { client.on(event.name, (...args) => event.execute(...args)); } }
In events/interactionCreate.js, export the event like this:
module.exports = { name: 'interactionCreate', async execute(interaction) { // All your button and modal code goes here }, };
This keeps your main file clean and makes it easier to add more interactions later without cluttering your code.
Frequently Asked Questions
Can I add more than one text input to a modal?
Yes. Each text input must go in its own ActionRowBuilder, and a modal can have up to five rows. If you need more than five fields, consider splitting the form into multiple modals or using a different approach like a web form.
What happens if a user does not fill in a required field?
Discord prevents them from submitting the modal. The field highlights in red and shows an error message. Only fields marked with setRequired(true) enforce this — optional fields let users submit blank.
How do I store applications in a database instead of just logging them?
Use a database library like sqlite3 or mongoose for MongoDB. In your modal submit handler, instead of just sending to a channel, insert the name, reason, and experience into a table. You can do both — send to a channel for immediate review and save to a database for records.
Can I edit the modal after showing it?
No. Once you call showModal(), the form is locked. If you need to change the fields, you have to create a new modal with a different custom ID and show that instead. Users cannot edit a modal after they submit it.
Why does my bot not respond to button clicks?
Check that your interactionCreate listener is actually loaded. Make sure the custom