skip to content

Building a Self-Hosted Spotify Song Request Twitch Panel

/ 8 min read

React , Next.js , TypeScript , PostgreSQL , Drizzle , Tailwind CSS

Overview

I wanted song requests without spam, queue chaos, or vendor lock-in. Existing bots felt clunky, so I built a system I control end to end.

The app handles auth, request intake, moderation, queue approval, and Spotify token refresh. It stays small enough to understand and complete enough to use on stream.

The scope grew from real problems. I started with one form that accepted a Spotify link. The first stream exposed the rest: wrong URLs, duplicate requests, junk submissions, auth gaps, and token expiry.

Each new layer came from one clear point of failure.

System Overview

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

Each part has a narrow job. A queue tool gets messy once one route or page owns too much state.

Discord OAuth And Identity

Login starts with Discord. The callback stores a small JSON payload in an http-only discord_user cookie. The payload only needs the Discord id and username.

That is enough for identity and for linking the user to a row in user_roles.

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

Every protected route reads that cookie. A missing cookie stops the request. A present cookie lets the app upsert a row in user_roles, keeping usernames current and moderation flags in the database.

Role Sync On Access

I upsert the user on each interaction. Usernames stay fresh, and bans take effect without cleanup work.

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 fail early.

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

Submission routes use the same early return for banned users.

Data Model

This system needs only a few clear tables.

spotify_tokens

This table stores the current access token, refresh token, and update time. I keep one row and refresh through that row instead of forcing a full user login again.

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

This table stores raw, unapproved links. The request stays pending until a moderator reviews 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

This table stores approved tracks with normalized metadata. The UI can show common views without hitting Spotify every 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 table decides trust. It stores 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()
})

Add indexes later after volume rises. Early on, the better win is a simple shape you trust.

Roles And Permission Flow

The request path is fixed. Read the discord_user cookie. Upsert the user. Stop early for banned users. Check moderator status on moderator routes. Continue only after those checks pass.

I did not need a policy engine. Two flags cover the real cases.

Admin Panel

The admin view lives under the users area and requires moderator access. The server validates the user on page load. The client defends the route too, but the server remains the source of truth.

The panel lists id, username, moderator state, and banned state. Buttons send 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 filter stay in the browser. That keeps the panel fast and avoids extra database work for simple moderation.

Request Lifecycle

A viewer submits a Spotify link. The server parses and validates the link first. A malformed URL returns a clear error. A rate-limited user gets a wait message. A valid request writes a pending row to track_requests.

Moderators review pending rows in a separate view. Approval triggers a Spotify lookup, inserts a normalized row into song_requests, and can queue the track to the active playback device.

Rejection marks the row rejected and keeps it for audit until cleanup.

A Spotify 401 triggers token refresh and one retry. A failed retry records the error and skips the action instead of failing silently.

API Layer

The public surface is small:

  • 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.

Each route validates early and returns a consistent JSON shape. The frontend should not need route-specific error handling for normal cases.

Moderation And Safety Layers

The main layers are input validation, request rate, ban checks, and token health.

Input Validation

The server rejects empty strings and non-Spotify links. It normalizes valid track URLs to one form, so duplicate checks stay reliable.

Rate Limiting

The first version uses an in-memory Map keyed by IP with a five-second minimum between submissions.

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
}

This is enough for one deployment region. Redis is the next stop after the app grows beyond one instance.

Ban Enforcement

The ban check runs prior to link parsing. A banned user should not consume more work than needed.

Error Messages

Routes return direct messages such as Invalid Spotify track link and Too many requests. Please wait. The UI can show them as a toast or inline warning.

Vague errors slow moderation and confuse users.

Spotify Token Strategy

Spotify access tokens expire, so every Spotify integration needs a refresh path. I wrap Spotify calls in a helper that refreshes once on 401 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
}
}

The app stores the new token and timestamp after refresh. Pre-refresh logic can come later after the failure rate calls for it.

Frontend And UX Notes

The app uses Next App Router and Tailwind. I kept the interface plain on purpose.

Fast forms, clear approval states, and simple moderation controls matter more than animation in a queue tool. The frontend calls APIs and renders state. Queue rules stay on the server.

Problems I Ran Into

The first issues came from environment differences. Cookie behavior changed between local and deployed environments until I made the cookie path explicit and stayed with the normal http-only flow.

The second group came from Spotify token expiry in the middle of an approval batch. The retry wrapper fixed that.

Spam and moderation drift showed up next. Rate limiting reduced duplicate spam enough for the first version. Upserting user_roles on each access kept usernames in sync after Discord changes.

Logging queue failures with a context string made silent failure easier to spot from the admin view.

The pattern is simple. Solve the next real failure. Keep the code plain.

What Comes Next

The next improvements are clear: queue updates through server-sent events or another light channel, a per-user cooldown on top of the IP limit, duplicate detection inside a moving window, an optional track length cap, upcoming order in the UI, phone-friendly controls, and soft remove versus hard reject.

Deploy Your Own

The deployment path is short. Clone the repo. Create a Neon Postgres project. Run the Drizzle migrations. Create a Discord app and set the callback URL. Create a Spotify app and copy the client id and secret.

Set the environment variables for Discord, Spotify, and the database. Deploy to Vercel. Log in and approve a test request.

After that, you own the full request path.

Closing

Owning this system lets you choose the friction level. You decide when requests are open, how moderation works, and which failures need more guardrails.

You are not waiting on a vendor bot to change behavior for your stream.

Source code: https://github.com/oyuh/streamthing