How the pieces fit
A music bot built this way is two programs. The bot is your discord.js code: it reads commands and decides what to play. Lavalink is a separate audio server: it finds the track, decodes it and sends the sound to the voice channel. A client library in the bot talks to Lavalink for you.
Splitting it this way keeps the bot small. It never touches audio, so it needs no FFmpeg, and the same node can play in many servers at once.
What you need
- A bot application and its token, from the Discord Developer Portal, invited to your server with the
botandapplications.commandsscopes. - A current LTS release of Node.js.
- A running Lavalink v4 node, with its address, port and password. Our Lavalink setup guide covers installing one, or a Lavalink plan gives you one already running.
Start the project
mkdir music-bot && cd music-bot
npm init -y
npm install discord.js lavalink-clientAdd "type": "module" to package.json, so the import lines below work. Keep the secrets out of the code, in a file named .env:
DISCORD_TOKEN=your-bot-token
CLIENT_ID=your-application-id
LAVALINK_HOST=127.0.0.1
LAVALINK_PORT=2333
LAVALINK_PASSWORD=your-node-passwordAdd .env to .gitignore. A token pushed to a public repository is found within minutes.
Connect the bot to Lavalink
Create index.js. The bot needs two gateway intents: Guilds, and GuildVoiceStates so it knows which voice channel a member is in.
import { Client, Events, GatewayIntentBits, MessageFlags } from 'discord.js'
import { LavalinkManager } from 'lavalink-client'
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
})
client.lavalink = new LavalinkManager({
nodes: [
{
id: 'main',
host: process.env.LAVALINK_HOST,
port: Number(process.env.LAVALINK_PORT),
authorization: process.env.LAVALINK_PASSWORD,
},
],
sendToShard: (guildId, payload) => client.guilds.cache.get(guildId)?.shard?.send(payload),
autoSkip: true,
client: { id: process.env.CLIENT_ID, username: 'MusicBot' },
})
// Lavalink needs Discord's voice events, so pass every raw event on.
client.on('raw', (data) => client.lavalink.sendRawData(data))
client.once(Events.ClientReady, () => {
client.lavalink.init({ ...client.user })
console.log(`Logged in as ${client.user.tag}`)
})
client.lavalink.nodeManager.on('connect', (node) => console.log(`Lavalink node ${node.id} connected`))
client.lavalink.nodeManager.on('error', (node, error) => console.error(`Lavalink node ${node.id}:`, error))
client.login(process.env.DISCORD_TOKEN)The three lines that people leave out are sendToShard, the raw listener and init. Without them the bot joins a channel and plays nothing.
Add a play command
Register a slash command once, in a separate file, register.js:
import { REST, Routes, SlashCommandBuilder } from 'discord.js'
const commands = [
new SlashCommandBuilder()
.setName('play')
.setDescription('Play a song')
.addStringOption((o) => o.setName('query').setDescription('A name or a link').setRequired(true)),
new SlashCommandBuilder().setName('skip').setDescription('Skip the current song'),
new SlashCommandBuilder().setName('stop').setDescription('Stop and leave the channel'),
].map((c) => c.toJSON())
const rest = new REST().setToken(process.env.DISCORD_TOKEN)
await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands })
console.log('Commands registered')Then handle it in index.js, above client.login:
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'play') return
const voiceChannel = interaction.member.voice.channel
if (!voiceChannel) {
return interaction.reply({ content: 'Join a voice channel first.', flags: MessageFlags.Ephemeral })
}
await interaction.deferReply()
const player =
client.lavalink.getPlayer(interaction.guildId) ??
client.lavalink.createPlayer({
guildId: interaction.guildId,
voiceChannelId: voiceChannel.id,
textChannelId: interaction.channelId,
selfDeaf: true,
})
if (!player.connected) await player.connect()
const result = await player.search({ query: interaction.options.getString('query') }, interaction.user)
if (!result.tracks.length) return interaction.editReply('Nothing found.')
const track = result.tracks[0]
await player.queue.add(track)
if (!player.playing) await player.play()
await interaction.editReply(`Queued **${track.info.title}**`)
})Node.js 20.6 and newer can read the .env file by itself. Register the commands, then start the bot:
node --env-file=.env register.js
node --env-file=.env index.jsSkip, stop and the queue
The player holds the queue, so the other commands are short. Add them inside the same InteractionCreate handler, with a check on interaction.commandName for each:
const player = client.lavalink.getPlayer(interaction.guildId)
if (!player) return interaction.reply('Nothing is playing.')
// /skip: an error here means the queue is empty
await player.skip()
// /stop: leaves the channel and clears the queue
await player.destroy()
// The queue itself
player.queue.current // the track playing now
player.queue.tracks // what comes nextThe lavalink-client repository documents the rest: volume, seeking, filters, autoplay and saving a queue across restarts.
YouTube and other sources
What your bot can play is decided by the Lavalink node, not by the bot. A search with no prefix uses the node’s default source. YouTube needs the youtube-source plugin on the node, which our setup guide shows, and that plugin has to be kept up to date because YouTube changes often.
Check the terms of any service you stream from. Discord has removed large music bots at the request of rights holders, and a bot in many servers is more exposed than one for your friends.
Keep it online
On your own computer the bot stops when the computer sleeps. For a bot that people rely on, run both parts on a server:
- Two managed plans. A Discord bot plan for the code and a Lavalink plan for the audio. Nothing to install or patch.
- One VPS for both. A VPS gives you root access to run the bot and Lavalink side by side, with the node bound to
127.0.0.1. More work, and more control.
Our guide to hosting a Discord bot 24/7 compares the options and covers restarts with systemd or pm2.
When it does not work
- The bot joins but plays nothing: the
rawlistener orsendToShardis missing, or theGuildVoiceStatesintent is not set. - The node never connects: the host, port or password does not match the node’s
application.yml, or a firewall is closed. - Nothing found for every search: the node has no source enabled for that search, or its YouTube plugin is out of date.
- The commands do not appear: run
register.jsagain, and check the invite included theapplications.commandsscope. Global commands can take a while to show. - Audio stutters: the node is short of memory or processor time. A node of your own avoids sharing one with strangers.