Make a Discord bot in Java with JDA

From nothing to a Java bot with slash commands, an embed and a button in your server, then a single jar you can run anywhere. You need a JDK (17 or newer), Gradle or an IDE that has it, and a Discord server you can add bots to.

15 min readBeginnerUpdated Examples tested with JDA 6.7 on Java 21, built with Gradle 9.8

Everything below was built with Gradle 9.8 and run against a test copy of Discord with JDA 6.7: every command, the button and the message reply answer as shown.

1. Create the application

  1. Go to the Discord Developer Portal and press New Application. The name is what people will see.
  2. Open Bot. Press Reset Token and copy it. This is the bot's password: anyone with it can run your bot. Keep it somewhere private for now.
  3. Privileged Gateway Intents can stay off until step 8, which needs Message Content Intent.

2. Invite it to your server

  1. Open OAuth2, then URL Generator.
  2. Tick bot and applications.commands.
  3. Under bot permissions, tick Send Messages (and whatever else your bot will need later). The invite link builder does the same.
  4. Open the URL at the bottom, pick your server, and authorise.

The bot shows up in the member list, offline. It comes online when your code runs.

3. Set up the project

You need a JDK, 17 or newer (java -version), and Gradle (gradle -v); IntelliJ IDEA comes with both. Make a folder called my-bot with this layout:

Text
my-bot/
  settings.gradle
  build.gradle
  src/main/java/com/example/bot/Main.java

settings.gradle names the project:

Text
rootProject.name = 'my-bot'

build.gradle adds JDA, a way to run the bot, and a plugin that later packs everything into one jar:

Gradle
plugins {
    id 'java'
    id 'application'
    id 'com.gradleup.shadow' version '8.3.5'
}

version = '1.0'

repositories { mavenCentral() }

dependencies {
    implementation 'net.dv8tion:JDA:6.7.0'
}

// Class files for Java 17, so the jar runs on 17, 21 or 25 whichever JDK built it.
tasks.withType(JavaCompile).configureEach { options.release = 17 }

application {
    mainClass = 'com.example.bot.Main'
}

Then, in the folder, run gradle wrapper once. It writes gradlew, which pins the Gradle version for this project; from now on use ./gradlew (.\gradlew on Windows).

4. Keep the token out of the code

The bot reads its token from an environment variable called BOT_TOKEN, so it is never in a file you might share or commit. Set it in the terminal you run the bot from. On macOS or Linux:

Terminal
export BOT_TOKEN=paste-your-token-here

In Windows PowerShell:

Terminal
$env:BOT_TOKEN = "paste-your-token-here"

5. Write the bot

All of it goes in Main.java: four slash commands, an embed and a button.

Java
package com.example.bot;

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.InteractionContextType;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.utils.TimeFormat;

import java.util.concurrent.ThreadLocalRandom;

public class Main extends ListenerAdapter {
    public static void main(String[] args) throws InterruptedException {
        String token = System.getenv("BOT_TOKEN");
        if (token == null || token.isBlank()) {
            System.err.println("Set BOT_TOKEN first.");
            System.exit(1);
        }
        JDA jda = JDABuilder.createLight(token)
                .addEventListeners(new Main())
                .build();
        jda.awaitReady();
        // Tell Discord which slash commands exist. Once per start is plenty.
        jda.updateCommands().addCommands(
                Commands.slash("hello", "Say hello"),
                Commands.slash("roll", "Roll a die")
                        .addOptions(new OptionData(OptionType.INTEGER, "sides", "How many sides").setMinValue(2)),
                Commands.slash("serverinfo", "Show some facts about this server").setContexts(InteractionContextType.GUILD),
                Commands.slash("counter", "A button that counts its clicks")
        ).queue();
        System.out.println("Logged in as " + jda.getSelfUser().getName());
    }

    @Override
    public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
        switch (event.getName()) {
            case "hello" -> event.reply("Hello, " + event.getUser().getAsMention() + "!").queue();
            case "roll" -> {
                int sides = event.getOption("sides", 6, OptionMapping::getAsInt);
                event.reply("You rolled " + ThreadLocalRandom.current().nextInt(1, sides + 1)).queue();
            }
            case "serverinfo" -> {
                Guild guild = event.getGuild();
                EmbedBuilder embed = new EmbedBuilder()
                        .setTitle(guild.getName())
                        .setColor(0x5865F2)
                        .addField("Members", String.valueOf(guild.getMemberCount()), true)
                        .addField("Created", TimeFormat.DATE_LONG.format(guild.getTimeCreated()), true);
                if (guild.getIconUrl() != null) embed.setThumbnail(guild.getIconUrl());
                event.replyEmbeds(embed.build()).queue();
            }
            case "counter" -> event.reply("Press it:")
                    .addComponents(ActionRow.of(Button.primary("count:0", "Clicked 0 times")))
                    .queue();
        }
    }

    @Override
    public void onButtonInteraction(ButtonInteractionEvent event) {
        String id = event.getComponentId();
        if (!id.startsWith("count:")) return;
        // The count lives in the button's own ID, so it survives a restart.
        int count = Integer.parseInt(id.substring("count:".length())) + 1;
        event.editButton(Button.primary("count:" + count, "Clicked " + count + " times")).queue();
    }
}
  • createLight starts JDA with no member or presence caches, which a bot like this does not need, and asks for no privileged intents.
  • awaitReady() waits until the bot is connected, so updateCommands() runs once it knows who it is.
  • The counter keeps its count in the button's ID (count:3). Nothing is stored in memory, so the button carries on counting after a restart.
  • Every JDA call that talks to Discord ends in .queue(). Without it, nothing is sent.

6. Run it

Terminal
./gradlew run

The console says Logged in as and your bot's name, the bot turns online, and the commands appear when you type / in your server. If they do not show up straight away, press Ctrl+R in Discord to reload it.

7. Make new commands appear instantly while you test

jda.updateCommands() registers commands globally, for every server the bot is in, and the Discord app can be slow to show changes. While you build, register them to your own server instead, where they change the moment you restart. Copy your server's ID (Developer Mode on under User Settings, Advanced, then right-click the server) and replace the jda.updateCommands().addCommands( line with:

JavaScript
        Guild myServer = jda.getGuildById("123456789012345678"); // your server's ID
        myServer.updateCommands().addCommands(

Switch back to jda.updateCommands() when the bot is ready for other servers.

8. Reply to ordinary messages

Slash commands need nothing more. To read what people type, the bot needs two more intents, and Message Content Intent switched on in the Developer Portal on the Bot page. Change the builder line in main to:

Text
JDA jda = JDABuilder.createLight(token, GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT)

add import net.dv8tion.jda.api.requests.GatewayIntent; and import net.dv8tion.jda.api.events.message.MessageReceivedEvent; at the top, and this method next to the other two:

Java
    @Override
    public void onMessageReceived(MessageReceivedEvent event) {
        if (event.getAuthor().isBot()) return; // never answer bots, including yourself
        if (event.getMessage().getContentRaw().equalsIgnoreCase("ping")) {
            event.getChannel().sendMessage("pong").queue();
        }
    }

9. Build one jar for hosting

Terminal
./gradlew shadowJar

This makes build/libs/my-bot-1.0-all.jar, with JDA and everything it needs inside, and runs with java -jar build/libs/my-bot-1.0-all.jar wherever Java 17 or newer is installed. A plain ./gradlew jar leaves the libraries out, and the bot stops with NoClassDefFoundError. The options.release = 17 line keeps the jar runnable on Java 17 even if you build it with a newer JDK.

Older examples and JDA 6

Many examples online are for JDA 5, and some of their code no longer compiles on JDA 6:

  • Button and ActionRow moved to net.dv8tion.jda.api.components.buttons and net.dv8tion.jda.api.components.actionrow. The old net.dv8tion.jda.api.interactions.components imports fail with package ... does not exist.
  • setGuildOnly(true) is gone: use setContexts(InteractionContextType.GUILD).
  • Components are added with addComponents(ActionRow.of(...)).

If it did not work

  • InvalidTokenException: The provided token is invalid!: BOT_TOKEN holds something other than the bot token (the client secret is a common mix-up), or the token was reset. Copy a fresh one from the Bot page.
  • Was shutdown trying to await status. Reason: You tried turning on an intent you aren't allowed to use (close code 4014): the code asks for Message Content, but it is off in the Developer Portal.
  • Set BOT_TOKEN first.: the variable is not set in this terminal. Set it again; it does not carry over to a new window.
  • UnsupportedClassVersionError: the jar was built for a newer Java than the one running it. Keep the options.release line, or run a newer Java.
  • NoClassDefFoundError: the jar was built with jar instead of shadowJar.
  • Commands do not appear: the bot was invited without applications.commands. Make a new invite link with both scopes and open it again.

Where to go next

When you want it running all the time, SnowServers runs Java bots on Java 17, 21 or 25 from $3 a month, with a 7-day free trial: upload the jar from step 9 in the panel's Files, paste the token in, and it stays online. How Java bots run here.

Run it here. Flurry keeps a bot like this online all day for $3 a month, and the first week is free.

Start the free week

Press / or Ctrl K on any page to search.