A static archive of thenexus.tv, a podcast network that produced 1,384+ episodes across 13 series between 2011 and 2022. Built with Astro, TailwindCSS, and React.
The original site ran on WordPress with the Nexus Core plugin and Coprime theme. This project replaces it with a fully static site generated from a JSON data export.
pnpm install
pnpm dev # Start dev server
pnpm build # Build static site to dist/
pnpm preview # Preview the built site
pnpm test # Run testsThe site runs as a standalone Node server using the @astrojs/node adapter, deployed in a Podman container.
Astro is configured with output: 'server' even though every page is prerendered as static HTML (export const prerender = true in each page file). This is intentional.
With output: 'static', the node adapter acts as a plain static file server. Requests that don't match a prebuilt file (e.g., /episode/ted20/) are 404'd immediately — Astro middleware never runs at request time in static mode, and neither do server-rendered routes. This is a significant and poorly documented limitation of the Astro node standalone adapter.
By using output: 'server' with prerendered pages, we get the best of both worlds:
- Pages are still prebuilt as static HTML at build time (same performance as static mode)
- Server-rendered route files can handle requests at runtime for paths that don't match a static file
This matters because the original WordPress site used /episode/ted20 (singular) while the archive uses /episodes/ted20/ (plural). Server-rendered redirect routes handle these so old links, bookmarks, and search engine results continue to work.
Important caveat: Even with output: 'server', Astro middleware does not reliably run at request time for paths without a matching route. Redirects are therefore implemented as server-rendered route files (e.g., src/pages/episode/[slug].ts, src/pages/[slug].ts) rather than in middleware. The middleware in src/middleware.ts exists as a secondary layer but should not be relied upon as the sole redirect mechanism.
| Old URL | Redirects to |
|---|---|
/episode/{slug} |
/episodes/{slug}/ (with slug normalization) |
/episode/ |
/episodes/ |
/{slug} (e.g., /ted20) |
/episodes/{slug}/ |
/category/{series}/feed |
/series/{series}/feed.xml |
Slug normalization converts dashed formats (atn-1) to the canonical dashless format (atn1).
pnpm build
node dist/server/entry.mjsThe production server (grail, Ubuntu 24.04) runs the site as a rootless Podman container managed by systemd via Quadlet. The full setup story — including Podman installation, rootless containers, lingering, and networking — is documented in .dev/plans/plan13.md.
Internet → Apache (TLS termination) → 127.0.0.1:4321 (Podman container)
The Quadlet unit file is at deploy/nexus-archive.container and gets installed to ~/.config/containers/systemd/ on the server. It tells systemd how to run the container: which image, port binding, environment variables, healthcheck, and restart policy.
Initial setup (one-time, on the server):
# Build the image from docker-compose.yml
podman compose build
# Install the Quadlet file and start the service
cp deploy/nexus-archive.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start nexus-archiveDo not run systemctl --user enable — Quadlet-generated services handle auto-start via the [Install] section automatically. It will fail with "unit is transient or generated," which is expected.
Deploying updates:
git pull
podman compose build
systemctl --user restart nexus-archiveManaging the service:
systemctl --user start nexus-archive # Start
systemctl --user stop nexus-archive # Stop
systemctl --user restart nexus-archive # Restart (after rebuilding image)
systemctl --user status nexus-archive # Check status + health
journalctl --user -u nexus-archive -f # Tail logsThe container exposes port 4321 on localhost only (127.0.0.1:4321). Apache reverse-proxies to it (see .dev/plans/plan12.md). A healthcheck pings /api/health every 30 seconds.
All data comes from a single JSON export (export/nexus-export-1770519097.json, ~4.8 MB). There are no content collections or database connections — src/data/index.ts loads the JSON at build time, resolves all relationships between series, episodes, people, and media, then exports typed query functions like getAllEpisodes(), getSeriesBySlug(), and getEpisodesByPerson().
Episode slugs follow the format {series_slug}{number} (e.g., atn1, tf130). Person slugs are derived from names (e.g., ryan-rampersad). Episodes can have fringe (spin-off) and parent (back-link) relationships to other episodes.
| Route | Description |
|---|---|
/ |
Homepage with recent episodes, series grid, and network stats |
/episodes/ |
Paginated episode listing (50 per page) |
/episodes/[slug]/ |
Episode detail with audio player, people, and related episodes |
/series/ |
All series directory |
/series/[slug]/ |
Series detail with paginated episodes |
/people/ |
People directory, separated into hosts and guests |
/people/[slug]/ |
Person profile with episode history |
/about/ |
Network history and archive information |
/contact/ |
Contact page |
/licenses/ |
Creative Commons license details |
See Hosting for details on how server-rendered redirect routes handle old WordPress-style URLs.
- AudioPlayer.tsx — React component with HTML5 audio playback, Web Audio API frequency visualization (with synthetic fallback for CORS-blocked sources), keyboard shortcuts, and a native player toggle. Client-side hydrated.
- EpisodeCard.astro — Episode card with series tag, date, duration, and description.
- Pagination.astro — Page navigation with smart range display and ellipsis.
- PersonCard.astro — Person card with Gravatar, role badge, and episode count.
TailwindCSS v4 with the typography plugin. Full dark mode support. Custom League Gothic font for headings. The site uses a neutral gray/stone palette with blue accents.
pnpm test # Run once
pnpm test:watch # Watch modeTests use Vitest and cover the data layer query functions and utility functions (slugifyName, makeEpisodeSlug, gravatarUrl, processContent, parseDuration, formatTime). Tests load the real JSON export — no mocks needed.
src/
components/ # Astro and React UI components
data/ # Data loading, types, and query functions
layouts/ # Base HTML layout
pages/ # All route pages
styles/ # Global CSS
export/ # Source JSON data export
public/ # Static assets (images, fonts, favicons)
.dev/ # Development plans and knowledge base