projects / nerve-media

Nerve Media

Nerve Radio's listener website in Next.js 16, plus the Go tools behind it that replaced the station's legacy manager, recorder and now-playing software.

fig. 0x02nerve radio

Overview

Nerve Radio is Bournemouth University's student radio station. I'm the lead developer on Nerve Media, the set of projects that rebuilt the station's software: the public website at nervemedia.org.uk, and the tools behind it that run the schedule, record the shows and say what song is on air.

This write-up is mostly about the website, Nerve Public: it's what listeners see, and most decisions behind the scenes came from something it needed. The other three projects get a shorter section at the end.

The home page between shows: the sustainer, Non-Stop Nerve, stands in for a show, and the player bar stays docked at the bottom
The home page between shows: the sustainer, Non-Stop Nerve, stands in for a show, and the player bar stays docked at the bottom

The problem

The station's software had grown over years, one tool at a time:

  • an old station manager (an Express API on MySQL, with Microsoft sign-in) that held shows, presenters and the schedule
  • an Electron app, also called Nerve Waves, that recorded shows and reported them to that API
  • an older now-playing handler that read Myriad's output file on the playout PC
  • an old design that fluctuated between different pages and subdomains, as such it was decided instead of trying to pull through old systems and upgrade from proprietary systems (aiir), we were just going to rewrite it into normal update-able code using Next.JS

Nobody had a full picture of where each tool lived, what job it did or who depended on it, and the website could only show what the other tools happened to leave somewhere it could read.

How I approached it

I started with the station's technical team, listing every legacy tool, what it was for and what the station still needed from it. Most of that happened over late nights in the Nerve Media office with Dom Tech, working out how previous teams had configured and linked everything together. Along the way we noted what we wanted to keep from the old sites and systems, both how things looked and how they worked. I took that into Figma and designed the email templates first, then the admin and public pages.

Once the station's manager at the time had signed off the designs, I built the website first, against them, on built-in sample data (NEXT_PUBLIC_USE_MOCK_DATA=true, which CI still builds with). Every time the design asked for something the station manager's API couldn't give, I wrote it up as an API issue: twelve of them, titled "Public site API: ..." (feature flags, shows, recordings, events, the live stream, now playing, the forms and so on). The API was built to close them, so the website wasn't bolted on afterwards; it was the spec for the public half of the API.

The website

Nerve Public is a Next.js 16 app (React 19, Tailwind 4, TypeScript) with only four runtime dependencies besides React and Next. All its content comes from the station manager's public /v1 API: the live player, the weekly schedule, show pages with recordings, events, About and two forms. Podcasts and news are built but shelved behind a two-line switch in lib/shelved.ts, so the design survives without fetching anything.

Sections can also be switched off from the station manager: the site reads /v1/features, drops them from the nav and 404s their pages. If the API can't be reached, every section counts as on and shows its own empty state, which beats a site with no navigation.

One player for the whole site

Playback has to carry on while you move around the site, so there's exactly one <audio> element, in the root layout inside a React context, and every play button talks to it. While live is enabled, the browser also polls /v1/live every 30 seconds for the show on air, the current song and the list of streams.

Most of the interesting code is about live radio not behaving like a recording:

components/player/PlayerProvider.tsx
const liveSrc = live?.streams[streamIndex]?.url ?? live?.streamUrl ?? "/v1/live/stream";const playLive = useCallback(() => {  setSource({ kind: "live" });  // A fresh connection every time, so resuming joins the stream live rather  // than replaying what was buffered while paused.  start(`${mediaUrl(liveSrc)}${liveSrc.includes("?") ? "&" : "?"}t=${Date.now()}`);}, [liveSrc, start]);const setStreamIndex = useCallback(  (i: number) => {    setStreamIndexState(i);    // Switch straight away if the live stream is playing.    const url = live?.streams[i]?.url;    if (url && source?.kind === "live" && audio.current && !audio.current.paused) {      start(`${mediaUrl(url)}${url.includes("?") ? "&" : "?"}t=${Date.now()}`);    }  },  [live?.streams, source?.kind, start],);// ...const toggle = useCallback(() => {  const el = audio.current;  if (!el) return;  if (!source) {    playLive();    return;  }  if (!el.paused) {    el.pause();    if (source.kind === "live") el.removeAttribute("src"); // stop downloading the stream    return;  }  if (source.kind === "live") playLive();  else el.play().catch(() => setError("Playback couldn't start. Please try again."));}, [source, playLive]);

Recordings take the other path: seeking, 15 second skips, and play on the loaded one just resumes.

Where the stream comes from

The browser doesn't connect to Icecast. It plays /v1/live/stream from the station manager's API, which relays Icecast itself. The relay keeps a single connection upstream however many people are listening, keeps it going when Icecast drops, and copies each chunk out to everyone:

apps/api/internal/live/relay.go
// burstBytes of the latest audio are kept, about 8 seconds at 128 kbps,// so a new listener starts hearing it straight away.burstBytes = 128 << 10// ...// broadcast sends a chunk to every listener, dropping any too far behind,// and keeps it for listeners who join next.func (r *Relay) broadcast(chunk []byte) {	r.mu.Lock()	defer r.mu.Unlock()	r.status.Bytes += int64(len(chunk))	for l := range r.listeners {		select {		case l.C <- chunk:		default:			delete(r.listeners, l)			close(l.C)			if len(r.listeners) == 0 {				r.emptySince = r.now()			}		}	}	r.burst = append(r.burst, chunk)	r.burstSize += len(chunk)	for r.burstSize > burstBytes && len(r.burst) > 1 {		r.burstSize -= len(r.burst[0])		r.burst = r.burst[1:]	}}

Each listener has a buffered channel. If a slow connection fills theirs, the select falls through to defaultL14-22 and they're dropped, instead of one bad phone signal holding up everyone. The relay also keeps the last 128 KBL24-29 and fills each new listener's channel with it first, so play starts sound straight away. If Icecast restarts, listeners are held for up to a minute while it reconnects, so they hear a gap rather than a dead player.

The schedule, in London time

The API sends days as plain dates (2026-10-15) and slots as station hours, and every listener should see London times whatever their device says. The catch is that JavaScript has no "date without a time" type: a plain date has to become a Date somewhere, and that's where days slip.

lib/station.ts
export const STATION_TIME_ZONE = "Europe/London";/** Noon UTC on the date, so formatting in UTC never shifts it a day. */function dateValue(date: string) {  return new Date(`${date}T12:00:00Z`);}function formatDate(date: string, options: Intl.DateTimeFormatOptions) {  return new Intl.DateTimeFormat("en-GB", { ...options, timeZone: "UTC" }).format(dateValue(date));}function formatInstant(iso: string | Date, options: Intl.DateTimeFormatOptions) {  return new Intl.DateTimeFormat("en-GB", { ...options, timeZone: STATION_TIME_ZONE }).format(    typeof iso === "string" ? new Date(iso) : iso,  );}/** The station date (YYYY-MM-DD) of an instant. */export function stationDate(at: Date | string = new Date()) {  return formatInstant(at, { year: "numeric", month: "2-digit", day: "2-digit" }).split("/").reverse().join("-");}// .../** "BST" or "GMT", whichever the station is on for that date. */export function zoneAbbreviation(date: string) {  return (    new Intl.DateTimeFormat("en-GB", { timeZone: STATION_TIME_ZONE, timeZoneName: "short" })      .formatToParts(dateValue(date))      .find((p) => p.type === "timeZoneName")?.value ?? ""  ).replace("GMT+1", "BST");}

So there are two kinds of value, never mixed. A date, like a day tab, is pinned to noon UTCL3-6 and formatted in UTCL8-10: 12 hours from either edge of the day, no timezone can push it over. An instant, like an event start, is formatted in Europe/LondonL12-16. "Today" is the London date of nowL19-21, worked out by Intl, so the clocks changing needs no special code, and the page labels times BST or GMTL26-32 (Intl says "GMT+1", hence the replaceL31).

The schedule page validates ?week= and ?day= against a date pattern, fetches that week and turns each day into rows: shows from the schedule, gaps filled by the sustainer and merged. The row on air now is highlighted and links to the player.

Reading the API without falling over

Pages are rendered on the server from an API in another container, which can be mid-restart during any deploy. I didn't want that to become a broken website, so every read goes through one small client:

lib/api/client.ts
/** GETs a path from the API, or from the built-in mock data when it's switched on. */export async function api<T>(path: string): Promise<T> {  // ...  let res: Response;  try {    // Never send an Authorization header: the API rejects a bad token with 401 even on /v1.    res = await fetch(`${API_URL}${path}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(8000) });  } catch (err) {    // Only a network failure or our own timeout means the API is unreachable.    // Anything else (like Next cutting a prerender short) must pass through.    if (!isNetworkError(err)) throw err;    throw new ApiError(503, "We couldn't reach the station's servers.");  }  // ...}export async function tryApi<T>(path: string): Promise<T | null> {  try {    return await api<T>(path);  } catch (err) {    if (!(err instanceof ApiError)) throw err;    if (err.status !== 404) {      console.error(`[nerve-api] GET ${path} failed:`, err instanceof Error ? err.message : err);    }    return null;  }}/** fetch() rejects with a TypeError when it can't connect, and a TimeoutError from AbortSignal.timeout. */function isNetworkError(err: unknown) {  return err instanceof TypeError || (err instanceof Error && err.name === "TimeoutError");}

Every request gets an 8 second timeoutL8. Pages call tryApi, which turns any API failure into nullL18-28, so the section renders its empty state ("The schedule isn't available right now") instead of an error page. A 404 is normal and isn't loggedL23.

The line that matters most is the rethrowL12. With Cache Components, Next.js 16 can cut a prerender short by rejecting a pending fetch. Treated like any other failure, that would become "API unreachable" and the page would be cached with empty sections. So only a real network failure or timeoutL31-33 counts as the API being down.

On top of this, each route has one cached function ("use cache", cacheLife("minutes")), except what's on air: that's fetched fresh per request inside <Suspense>, so the cached shell arrives straight away and the live parts stream in. Audio and images skip the Next server entirely; the browser fetches them from the API.

Events, calendars and forms

"Add to calendar" on an event needs no server route: lib/ics.ts builds the iCalendar text in the page and hands it to the link as a data:text/calendar URL.

The two forms (a message to the studio, and the get-involved enquiry) post from the browser straight to the API, so its per-visitor rate limits apply to the real visitor. Both have a spam trap: a website field moved off-screen, out of the tab order and hidden from screen readers. People never fill it in; simple bots fill in everything. The API answers those with a normal { "ok": true } and throws them away, so a bot learns nothing. The enquiry handler also checks the email round-trips exactly through a parser and rejects names containing line breaks.

Shipping it

The site is a standalone Next.js server in a three-stage Docker image, running as a non-root user. Pushing to the deploy branch runs a GitHub Actions workflow that lints and builds with the sample data, then connects to the server over SSH with a pinned host key.

The station manager's deploy script already runs containers behind Apache with Let's Encrypt, so I reused it. Its repo is private and this workflow has no token for it, so the workflow fetches the copy already deployed on the server and runs only the public site's steps: upload, build and start, health check. Images are tagged by commit, and a rollback input switches back to an earlier one.

Behind the scenes

How the Nerve projects fit together

Nerve Center: the station manager

Nerve Center replaces the old station manager: a Go API built on Bosun, my own web framework, plus a Next.js web app for members, MySQL, a Garage bucket behind a CDN for images, Icecast and Active Directory sync. It owns the schedule, shows, recordings, accounts and everything the public site shows.

Features that came out of working with the station include shifts and breaks (presenters can only request slots inside their shifts), CSV invites up to 500 at a time, cancelling a show while it's on air, and listener counts plus every track played, kept for the music licence returns. In October 2026 we also dropped the lead host: every presenter of a show is now equal.

Moving off the old manager is an import that only reads a .sql dump, never the live database. The dump holds members' details, so it's deleted after 24 hours. It only adds, remembers every row it brought across so re-running is safe, turns old presenters into names rather than accounts, and by default covers the last 3 years, staging old recordings without touching a file.

Nerve Waves: the recorder

Nerve Waves replaces the old Electron recorder of the same name: a Go Windows service on the studio PC that records each show from the USB sound card with ffmpeg (DirectShow in, MP3 out).

The rule I built it around is that recording never waits for the API. When a show comes on, ffmpeg starts straight away under an ID the recorder makes itself, and only then does it ask the API to create the recording, retrying until the API answers, even after the show ends. The next show's recording starts before the last one stops, so there's no gap. Finished files wait in a local spool folder, tracked by a pending.json that's written to a temporary file and renamed into place. Files are copied to the shows folder as .part, size-checked, renamed, and never overwrite anything.

A second program, nerve-waves-migrate, moves the old recorder's files that the import staged into the new <academic year>\<show>\<recording ID>.mp3 layout, with a dry-run mode and a CSV report.

Nerve Track: now playing

Nerve Track is a small Go program on the playout PC that tells Nerve Center which song is on air. The first version polled Myriad's HTTP API every 5 seconds; in October 2026 I switched it to the XML file Myriad's OCP output writes on every item change, the same source the old handler used, so it needs no network access to Myriad and no Myriad API key.

A song, from Myriad to the listener's screen

It posts songs only, once per change; adverts and jingles clear the current song. Each post carries the song's length plus two seconds, so if nothing follows, the website stops showing it by itself. Nerve Track is one of three sources Nerve Center can use; the others are the stream's own metadata and Icecast's status page.

Outcome

The website is live at nervemedia.org.uk, and the station manager, recorder and now-playing bridge replace the old manager, the Electron recorder and the old handler. Each project has docs written for whoever takes over from me. Presenter invites have gone out and the schedule has been set up and published. Nerve Center already has 91 users and 61 shows, with more to come as the years goes on.

What I learned

  • Let the front end write the back end's spec. Building the site first and turning every gap into an API issue meant the public API is exactly what the site uses.
  • Fail soft on purpose. A restarting API shows up as an empty section, a recorder that can't reach the API keeps recording, and a bot that trips the spam trap gets a success. Each was a decision about what the person on the other end should see.
  • Be precise about what counts as an error. One rethrow is the difference between a framework signal and an apparent outage.