skip to content

Building a Self-Hosted Spotify Song Request Twitch Panel

How I built a self-hosted song request system with auth, moderation, queue control, and Spotify token management.

9 min read

Why I built my own

I wanted song requests on stream without the spam, without the queue chaos, and without waiting on some bot to change its behavior on me mid-season. Every existing option was clunky in at least one of those ways, so I built the thing end to end.

It handles auth, request intake, moderation, queue approval, and Spotify token refresh. Small enough to hold in my head, complete enough to run on stream.

The scope came out of real problems. I started with a single form that took a Spotify link. Then the first stream happened and exposed everything else at once: wrong URLs, duplicate requests, junk submissions, no idea who was submitting what, and tokens expiring mid-session.

Every layer that exists now got added because something specific broke.

The whole system in one breath

Discord OAuth for identity, PostgreSQL and Drizzle for storage, a role table for moderators and bans, intake tables for raw requests and approved songs, a single Spotify token row, API routes for queue actions, a small admin panel, an in-memory rate limiter, and a retry wrapper around Spotify calls.

Each piece does one small job. Queue tools get messy fast once a single route or page owns too much state, so I worked at not letting that happen.

Discord OAuth and identity

Login goes through Discord. The callback stores a small JSON payload in an http-only discord_user cookie, just the Discord id and username, because that is all identity needs here.

const cookieStore = cookies()
cookieStore.set('discord_user', JSON.stringify({
id: user.id,
username: user.username
}), {
httpOnly: true,
path: '/'
})

Every protected route reads that cookie. No cookie, no request. If it’s there, the app upserts a row in user_roles, which keeps usernames current and moderation flags in the database.

Syncing roles on access

I upsert the user on every interaction. It is a little wasteful, and it means usernames never go stale and a ban takes effect on the next request with no cleanup job.

await db.insert(userRoles).values({
id: user.id,
username: user.username,
isModerator: false,
isBanned: false
}).onConflictDoUpdate({
target: userRoles.id,
set: { username: user.username }
})

Actually enforcing it

Moderator routes check one flag and bail early:

if (!role || !role.isModerator) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}

Submission routes do the same early return for banned users.

The four tables

spotify_tokens

The current access token, refresh token, and when they last changed. One row, refreshed in place, so nobody ever does the full login dance twice.

export const spotifyTokens = pgTable('spotify_tokens', {
id: text('id').primaryKey(),
access_token: text('access_token').notNull(),
refresh_token: text('refresh_token').notNull(),
updated_at: timestamp('updated_at').notNull()
})

track_requests

Raw unapproved links. A request sits here as pending until a moderator looks at it.

export const trackRequests = pgTable('track_requests', {
id: text('id').primaryKey().default(sql`gen_random_uuid()`),
link: text('link').notNull(),
requestedBy: text('requested_by').notNull(),
status: text('status').default('pending'),
createdAt: timestamp('created_at').defaultNow()
})

song_requests

Approved tracks with normalized metadata, so the UI can render the queue without hitting Spotify every single time.

export const songRequests = pgTable('song_requests', {
id: text('id').primaryKey().default(sql`gen_random_uuid()`),
spotifyUri: text('spotify_uri').notNull(),
title: text('title').notNull(),
artist: text('artist').notNull(),
requestedBy: text('requested_by').notNull(),
approved: boolean('approved').default(false),
rejected: boolean('rejected').default(false),
createdAt: timestamp('created_at').defaultNow()
})

user_roles

The trust table. Moderator state, ban state, and a username for display.

export const userRoles = pgTable('user_roles', {
id: text('id').primaryKey(),
username: text('username'),
isModerator: boolean('is_moderator').default(false),
isBanned: boolean('is_banned').default(false),
createdAt: timestamp('created_at').defaultNow()
})

No indexes yet. They can show up when the row count asks for them. Early on the better win is a schema you trust.

Roles and permissions

The request path never changes. Read the discord_user cookie, upsert the user, stop early if they’re banned, check the moderator flag on moderator routes, then do the actual work.

I looked at something fancier and then counted the cases. Two boolean flags cover all of them. A song request panel does not need a policy engine.

The admin panel

Lives under the users area and requires moderator access. The server validates on page load, and the client defends the route too, but the server stays the source of truth.

The panel is a list: id, username, moderator state, banned state. Buttons fire small PATCH requests carrying only the changed field.

await fetch('/api/users/123456789/role', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isModerator: true })
})

Search and filtering happen in the browser. At a few hundred rows that is instant and costs the database nothing.

What happens to a request

Someone submits a Spotify link. The server parses and validates first. A malformed URL gets a clear error, a rate-limited user gets told to wait, and a valid request becomes a pending row in track_requests.

Moderators review pending rows in their own view. Approving triggers a Spotify lookup, writes a normalized row into song_requests, and can push the track straight to the active playback device. Rejecting marks the row and keeps it around for audit until cleanup.

If Spotify comes back with a 401, the app refreshes the token and retries once. If the retry fails too, it records the error and skips the action instead of failing silently. Silent failures are the worst kind in a queue tool, because you find out twenty minutes later when someone asks where their song went.

Each gate drops some requests along the way. Here is where they fall off, from raw submission to a track sitting in the Spotify queue:

Where requests drop off, per 100 submissions
Loading chart…

The API routes

There are six of them.

  • GET /api/user returns the current user from the cookie.
  • POST /api/spotify/submit accepts a request link and applies validation plus rate limiting.
  • GET /api/spotify/requests returns pending or approved request data for moderators.
  • PATCH /api/spotify/requests approves or rejects a request.
  • GET /api/users and PATCH /api/users/:id/role back the moderation panel.

Every route validates early and returns the same JSON shape, so the frontend doesn’t need route-specific error handling for the normal cases.

Moderation and safety

Four layers do the work. Input validation, rate limiting, ban checks, and token health.

Input validation

Empty strings and non-Spotify links get rejected at the door. Valid track URLs get normalized to one canonical form, so the same song submitted three different ways still reads as one duplicate.

Rate limiting

First version is an in-memory Map keyed by IP with a five-second minimum between submissions. It cut the duplicate spam on the first stream I ran it for.

const rateLimitMap = new Map<string, number>()
function isRateLimited(ip: string) {
const now = Date.now()
const last = rateLimitMap.get(ip) || 0
if (now - last < 5000) return true
rateLimitMap.set(ip, now)
return false
}

Ban enforcement

The ban check runs before link parsing. A banned user should not cost the server a URL parse.

Error messages

Routes return blunt stuff like Invalid Spotify track link and Too many requests. Please wait. The UI can show them as a toast or inline warning as-is.

A vague error means a moderator has to go find out what happened, so the routes say what went wrong.

Spotify tokens

They expire, so every Spotify call goes through a helper that catches a 401, refreshes once, and retries.

async function withSpotify(fn) {
try {
return await fn()
} catch (err) {
if (err.response?.status === 401) {
const refreshed = await refreshAccessToken()
if (!refreshed) return { error: 'Token refresh failed' }
return await fn()
}
throw err
}
}

After a refresh the new token and timestamp get stored. Most calls pass first try, about one in nine hits an expired token and succeeds on the retry, and the rest fail outright:

Spotify call outcomes, per 100 calls
Loading chart…

Refreshing before expiry is the obvious upgrade. At a 2 percent hard failure rate it has not earned the code yet.

Frontend notes

Next App Router and Tailwind, and I kept the interface plain on purpose. In a queue tool, a fast form and an obvious approval state beat any animation.

The frontend calls APIs and renders state. Queue rules stay on the server.

Problems I ran into

The first batch was all environment differences. Cookie behavior kept shifting between local and deployed until I set the cookie path by hand and stuck with the plain http-only flow.

Then Spotify tokens started expiring in the middle of an approval batch, which is exactly the failure the retry wrapper exists for now.

Spam and moderation drift showed up after that. The rate limit cut duplicate spam down enough for a first version, and upserting user_roles on every access kept usernames in sync after people changed them on Discord.

Last quality-of-life fix was logging queue failures with a context string, which made silent failures visible from the admin view instead of invisible everywhere.

The pattern through all of it was the same. Solve the next real failure, keep the code plain.

What’s next

Queue updates over server-sent events or some other light channel, a per-user cooldown on top of the IP limit, duplicate detection inside a moving window, an optional track length cap, showing the upcoming order in the UI, phone-friendly controls, and a soft remove option next to hard reject.

Running your own

Short path. Clone the repo, make a Neon Postgres project, run the Drizzle migrations, create a Discord app with the callback URL set, create a Spotify app for the client id and secret.

Set the env vars for Discord, Spotify, and the database, deploy to Vercel, log in and approve a test request.

After that you own the full request path, which was the point. You decide when requests are open, how strict moderation gets, and which failures to lock down harder. No waiting on someone else’s bot to change how it works for your stream.