Writing a Discord music bot looks simple at first glance — "join a channel, play a song" — but it gets serious once you add solid queue management, uninterrupted audio streaming, and an architecture that runs across many servers at once. In this guide we build a modern bot in two layers: discord.js for the bot logic, and a separate audio server, Lavalink, for decoding and streaming. We also briefly cover the direct approach with @discordjs/voice for smaller bots.
Why Lavalink? The difference from @discordjs/voice
@discordjs/voice is the official library that lets your bot join a voice channel and send a raw PCM/Opus stream. On its own it is enough to play music: you fetch a stream source (for example ytdl-core or play-dl), turn it into a resource with createAudioResource, and play it through an AudioPlayer. The problem with this approach is scaling: decoding and transcoding audio puts heavy load on the same Node process your bot runs in.
Lavalink is a standalone, Java-based audio node that takes over this heavy lifting. Your bot tells Lavalink "play this track in this channel" over a WebSocket, and Lavalink handles all the decoding and streaming. The advantages:
- CPU load is moved off the bot; a single node can serve hundreds of guilds.
- Built-in plugin support for YouTube, SoundCloud, Bandcamp, Twitch and HTTP sources.
- Session continuity and robust reconnection even if the bot restarts.
For a small, single-server hobby bot, @discordjs/voice is more than enough. If you plan to play 24/7 across many servers, Lavalink is almost mandatory.
Setting up the project and registering the bot
First initialise a Node.js project (Node 18+ recommended) and install the required packages:
mkdir music-bot && cd music-bot
npm init -y
npm install discord.js shoukaku dotenv
Here shoukaku is a mature client library that talks between discord.js and Lavalink (alternatives include lavalink-client, which is actively maintained in place of erela.js). Create an application in the Discord Developer Portal, grab a bot token, and put it in a .env file. When inviting the bot, grant the bot and applications.commands scopes plus the Connect and Speak voice permissions.
Running the Lavalink node
Lavalink ships as an executable JAR and requires Java 17+. Download the official release and place an application.yml next to it:
server:
port: 2333
lavalink:
server:
password: "a-strong-password"
Then start it with java -jar Lavalink.jar. On current versions you may need to configure a separate plugin (such as youtube-source) for the YouTube source; check the startup logs to see which sources loaded. In production, make this process persistent with systemd or Docker.
Connecting the bot to Lavalink
With Shoukaku, the node connection and a basic client are set up like this:
const { Client, GatewayIntentBits } = require('discord.js');
const { Shoukaku, Connectors } = require('shoukaku');
require('dotenv').config();
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
],
});
const nodes = [{
name: 'main',
url: 'localhost:2333',
auth: process.env.LAVALINK_PASSWORD,
}];
const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes);
shoukaku.on('error', (_, err) => console.error(err));
client.login(process.env.DISCORD_TOKEN);
Note: the GuildVoiceStates intent is required to track voice states. Without it, the bot cannot see which channel a user is in.
Building the play queue
The real music experience is defined by the "queue": lining up simultaneous requests, advancing automatically after a track ends, and leaving when the channel empties. Keep a separate queue per guild. A simple play flow:
async function play(interaction, query) {
const node = shoukaku.options.nodeResolver(shoukaku.nodes);
const result = await node.rest.resolve(`ytsearch:${query}`);
if (!result || !result.data.length) {
return interaction.reply('No results found.');
}
const track = result.data[0];
const player = await shoukaku.joinVoiceChannel({
guildId: interaction.guildId,
channelId: interaction.member.voice.channelId,
shardId: 0,
});
player.playTrack({ track: { encoded: track.encoded } });
return interaction.reply(`Now playing: **${track.info.title}**`);
}
In a real bot you wrap this in a queue class: on the player.on('end', ...) event you pull the next track from the array and play it, and when the array is empty you leave with shoukaku.leaveVoiceChannel after a grace period. Holding the queue in memory as a Map<guildId, Queue> is enough for most bots.
Adding slash commands
Modern Discord bots use slash commands instead of text triggers. Register the commands once over REST and handle them in the interactionCreate event:
const { SlashCommandBuilder, REST, Routes } = require('discord.js');
const commands = [
new SlashCommandBuilder()
.setName('play')
.setDescription('Play a song')
.addStringOption(o =>
o.setName('query').setDescription('Song name or URL').setRequired(true)),
].map(c => c.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID),
{ body: commands },
);
Add skip, queue, pause, stop and volume the same way. Every command other than play reads and updates queue state, so centralising your queue manager in a single module makes the code far easier to maintain.
Common pitfalls
- Bot joins the channel but there is no sound: usually the Lavalink node cannot resolve the source. Check the node logs and that the source plugin loaded.
- "Used disallowed intents": you forgot to enable the relevant intents in the developer portal.
- Bot invited but commands don't show up: global commands take time to propagate; during development, registering to a specific guild works instantly.
Frequently Asked Questions
Is Lavalink mandatory, or is plain discord.js enough?
For a single server with occasional playback, @discordjs/voice plus a source like play-dl is enough. If you'll play continuously across many servers, Lavalink offers a far more stable experience because it moves the CPU load off the bot.
Is there anything I should watch out for regarding copyright?
Audio sources and platform terms of service change over time. For a commercial or large-scale project, make sure the content you play complies with the relevant platform's terms, and prefer licensed APIs where possible.
Where should I host the bot?
For low latency, run the bot and the Lavalink node on a VPS in the same region. Free platforms that "sleep" are unsuitable for music bots; you need a server that runs continuously.
Want your own music bot? For a server-specific bot built end to end with queue management, slash commands and Lavalink infrastructure, get in touch with me.