Updating the UH CS Discord directory

July 19, 2026

Around a year ago, I built a small site that lists Discord servers for UH Computer Science courses: UH Comp Sci Discord List. For a long time it was pretty much a static React app plus a JSON file of invite links. That worked for a while, but I ran out of people who could maintain the list of servers.

UH CS Discord directory showing expanded course rows with professor Discord cards
The directory UI

I came to the conclusion that this needed to be crowd-sourced, so the site needed an overhaul. In the meantime I learned a bit about automation workflows and got to upgrade from an annoying daily commit schedule.

This post is about the recent rewrite of how that site stays up to date.

Link to the repo: uhcs-disc

What it used to do

The course list lived in public/servers.json. Each professor entry was a name and a Discord invite URL. A separate file, server-metadata.json, held the live Discord name, icon, and member count for each invite.

That metadata file was refreshed by a GitHub Action on a nightly cron. The Action called Discord's invite API with a bot token, rewrote the JSON, and committed the result back to the repo. On paper that is fine. The directory itself is simple but the ops around it were not.

GitHub commit history showing daily Update Discord server metadata commits from actions-user
The nightly metadata commits before the change

Changes: Automation

The first change was to stop writing Discord state into the repo at all.

There is now a Vercel serverless function at /api/metadata. On a cache miss it reads the invite codes from servers.json, fans out to Discord's invite API, and returns { lastUpdated, servers }. The response is edge-cached for about a day (s-maxage=86400 with stale-while-revalidate), so Discord only gets hit when the cache expires, not once per page view.

The React app still ships a bundled server-metadata.json for first paint and offline fallback. When /api/metadata returns, the UI swaps in the live copy. If the function fails, the page keeps working on the snapshot.

Discord rate limits are annoying on the invite endpoint. With DISCORD_BOT_TOKEN set, the function runs a small parallel pool. Without it, it crawls one request at a time and respects Retry-After. Either way, transient failures keep the last known value for that invite instead of blanking the card. Only a hard 404 gets pruned.

The GitHub Action is gone. No more nightly commits, no PAT for this job, no waiting on cron for a new invite to pick up a name and icon.

Changes: Crowd-Sourcing Lists

I am using Neon here. Free Postgres, pooled connections that work nicely with short-lived Vercel functions, and no public table API. Unlike something like Supabase's auto HTTP layer, the only thing that can touch these tables is code holding DATABASE_URL. In this project that means the serverless functions under api/, never the browser bundle.

CRA will happily inline any REACT_APP_* env var into the client. So the rule is boring and strict: database credentials never get that prefix, and the React app never imports the DB helper.

The schema:

  • courses and servers are seeded from today's servers.json
  • a submission is just a servers row with status = 'pending'
  • reports and click_events dedupe by anonymous session (and a salted IP hash on the server)
  • Postgres triggers keep report_count / click_count consistent and can auto-hide a link after enough distinct reports

If you just want the directory, it is here: UH Comp Sci Discord List.