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.
Overview
I wanted song requests on stream without the usual spam, queue chaos, or being stuck with someone else’s bot. The existing options all felt clunky in some way, so I built the thing myself, end to end.
The app handles auth, request intake, moderation, queue approval, and Spotify token refresh. It’s small enough that I can hold the whole thing in my head, and complete enough to actually run on stream.
The scope grew out of real problems, not a spec. I started with a single form that accepted a Spotify link. Then the first stream happened and exposed everything else: 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.
System Overview
Here’s the whole thing 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 has one narrow job. Queue tools get messy fast once one route or page starts owning too much state, so I tried hard not to let 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’s genuinely 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 the cookie’s there, the app upserts a row in user_roles, which keeps usernames current and moderation flags living in the database.
Role Sync On Access
I upsert the user on every interaction. Sounds wasteful, but it means usernames never go stale and bans take effect immediately, no cleanup job needed.
await db.insert(userRoles).values({ id: user.id, username: user.username, isModerator: false, isBanned: false}).onConflictDoUpdate({ target: userRoles.id, set: { username: user.username }})Enforcement
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.
Data Model
Four tables cover everything.
spotify_tokens
The current access token, refresh token, and when they last changed. One row, refreshed in place, so nobody ever has to do 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 show 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
This is 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()})I skipped indexes for now. They can show up when the volume demands them. Early on, the better win is a simple shape you actually trust.
Roles And Permission Flow
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, and only then do the actual work.
I briefly considered something fancier, then realized two boolean flags cover every real case I have. No policy engine needed.
Admin Panel
The admin view lives under the users area and requires moderator access. The server validates on page load, and while the client defends the route too, the server stays the source of truth.
The panel itself is just a list: id, username, moderator state, banned state. Buttons fire small PATCH requests with 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. For a moderation panel this size, that keeps things snappy without any extra database work.
Request Lifecycle
A viewer submits a Spotify link. The server parses and validates it 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 one 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 answers 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 in a queue tool are the worst kind.
Each gate drops some requests along the way. Here’s roughly where they fall off, from raw submission to a track actually sitting in the Spotify queue:
API Layer
The public surface stays small:
GET /api/userreturns the current user from the cookie.POST /api/spotify/submitaccepts a request link and applies validation plus rate limiting.GET /api/spotify/requestsreturns pending or approved request data for moderators.PATCH /api/spotify/requestsapproves or rejects a request.GET /api/usersandPATCH /api/users/:id/roleback 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 Layers
Four layers do the heavy lifting: 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, which is what keeps duplicate detection honest.
Rate Limiting
The first version is an in-memory Map keyed by IP, with a five-second minimum between submissions. Not glamorous, but it works.
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. If someone’s banned, there’s no reason to spend even a URL parse on them.
Error Messages
Routes return blunt messages like Invalid Spotify track link and Too many requests. Please wait. The UI can show them as a toast or inline warning, as-is.
Vague errors slow moderation down and confuse viewers, so I just don’t write vague errors.
Spotify Token Strategy
Spotify access tokens expire. That’s just life with their API, 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. In practice most calls pass on the first try, a small slice hits an expired token and succeeds on the retry, and very few fail outright:
Proactively refreshing before expiry is a possible upgrade, but I’m not adding it until the failure rate says I have to.
Frontend And UX Notes
The app is Next App Router and Tailwind, and I kept the interface deliberately plain. In a queue tool, fast forms and obvious approval states beat animations every time.
The frontend calls APIs and renders state. The queue rules stay on the server, where they belong.
Problems I Ran Into
The first batch of issues came from environment differences. Cookie behavior kept shifting between local and deployed environments until I made the cookie path explicit and stuck with the boring http-only flow.
Then came Spotify tokens 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 next. 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.
The last quality-of-life fix was logging queue failures with a context string, which made silent failures actually visible from the admin view.
The pattern through all of it is simple: solve the next real failure, keep the code plain.
What Comes Next
There’s a clear list of next steps: queue updates over server-sent events or some other light channel, a per-user cooldown on top of the IP limit, duplicate detection within 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.
Deploy Your Own
If you want to run this yourself, the path is short. Clone the repo, create a Neon Postgres project, run the Drizzle migrations, create a Discord app with the callback URL set, and create a Spotify app to grab the client id and secret.
Set the environment variables for Discord, Spotify, and the database, deploy to Vercel, then log in and approve a test request.
After that, you own the full request path.
Closing
That ownership is really the whole point. You choose when requests are open, how strict moderation is, and which failures deserve more guardrails.
You’re not waiting on some vendor bot to change its behavior for your stream.