Overview#
Echo is a website template for a podcast, built for Overtone, a fictional independent interview show about how things get made. Its hosts, Nadia Ferreira (an industrial designer) and Eli Brandt (a science journalist), record in a former piano workshop in Philadelphia and have published 62 episodes over four seasons. The site has a home page with the latest episode, an episode archive, episode pages with chapters, show notes and a transcript, hosts, subscribe, advertise and be-a-guest pages, and an audio player that keeps playing while the visitor moves around the site. This is the Next.js version: Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. The design is dark-first: a blackcurrant night background, wide Syne headlines, mono labels and colourful SVG cover art. This guide covers version 1.0.0. See it live at freeframe.dev/templates/echo.
Quick start#
You need Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm devOpen http://localhost:3000. Edits to pages, components and data reload instantly.
| Script | What it does |
|---|---|
pnpm dev |
Development server |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
TypeScript |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with the latest episode and its player, "listen on" links, guest ticker, four featured episodes, topics with counts, hosts, listener reviews, newsletter form |
/episodes |
src/app/episodes/page.tsx |
Archive: search, season and topic filters, sort, filter chips, load more, empty state (/episodes?topic=sound&season=4&q=room&sort=oldest) |
/episode |
src/app/episode/page.tsx |
One episode: cover, full player with chapter markers, about and pull quote, chapters (#chapters), show notes (#notes), transcript (#transcript), guest card, share box (#share), related episodes. /episode?id=18&t=754 opens episode 18 at 12:34; without id it shows the latest episode |
/hosts |
src/app/hosts/page.tsx |
Host cards, numbers, timeline, how an episode is made, press quotes (#press), team |
/subscribe |
src/app/subscribe/page.tsx |
App links (#apple-podcasts, #spotify, #youtube, #pocket-casts, #overcast, #rss), copyable RSS feed, next release date, FAQ, email sign-up |
/sponsor |
src/app/sponsor/page.tsx |
Audience stats, growth line chart, occupations donut, age and country bars, ad formats, rate card (#rates), enquiry form (#enquire; /sponsor?format=season ticks a format) |
/guest |
src/app/guest/page.tsx |
What we look for, what happens next, pitch form |
| 404 | src/app/not-found.tsx |
"Dead air" page with links back into the site |
Project structure#
echo/
src/app/
layout.tsx <html>, fonts, skip link, header, footer, mini player, toast, back to top
page.tsx home page; one folder per route below it (episodes/, episode/, ...)
not-found.tsx 404 page
globals.css Tailwind CSS: design tokens (@theme) and component classes
fonts.ts next/font/local setup for the three fonts
icon.svg favicon
opengraph-image.png social image (1200×630)
src/components/
player/PlayerUI.tsx player building blocks: <PlayerRoot>, <Toggle>, <Wave>, <Rate>, <Volume>...
player/HeroPlayer.tsx the home page player (always the newest episode)
player/MiniPlayer.tsx the mini player docked to the bottom of every page
EpisodePage.tsx the episode page: player, chapters, notes, transcript, guest, share
Archive.tsx the /episodes filters and results
EpisodeCards.tsx episode cards, archive rows and play buttons
Art.tsx SVG cover art, waveforms and guest avatars
Charts.tsx advertise page charts
Forms.tsx the four forms; useValidatedForm.ts is their validation hook
Subscribe.tsx RSS copy button and next release date
SiteHeader.tsx, SiteFooter.tsx, ListenOn.tsx, Icons.tsx, Toast.tsx, RevealObserver.tsx,
BackToTop.tsx, Year.tsx
src/data/
episodes.ts every episode (EPISODES)
details.ts chapters, show notes, transcripts and guest bios (DETAILS)
show.ts show name, feed, credits, topics and seasons (SHOW, TOPICS, SEASONS)
site.ts menu, podcast app links, contact addresses (NAV, APPS, CONTACT)
audience.ts advertise page chart numbers (AUDIENCE)
src/lib/
player.ts the player engine and saved progress
episodes.ts look-ups, generated chapters/notes/transcripts, archive filters
format.ts, art.ts, validate.ts, toast.ts, metadata.ts, asset.ts, use-class.ts, use-client-value.ts,
use-document-meta.ts
src/fonts/ woff2 font files and their license (OFL.txt)
public/ host portraits (host-nadia.svg, host-eli.svg), og-image.svg source
next.config.ts static export and optional base path
template.json, README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The design tokens live in the @theme block of src/app/globals.css. Every utility such as bg-night,
text-muted or text-lilac comes from them.
| Token | Default | Used for |
|---|---|---|
--color-night |
#0e0c13 |
Page background |
--color-ink-900, --color-ink-800, --color-ink-700 |
#15121c, #1c1826, #272132 |
Sections, cards and inputs, hover states |
--color-line |
#342c42 |
Borders, rules, the unplayed part of waveforms |
--color-paper |
#f4efe6 |
Headings and body text |
--color-muted, --color-subtle |
#b6adc2, #948aa3 |
Secondary text and captions |
--color-lilac |
#b9a4ff |
Main accent: buttons, links, the played part of waveforms, focus rings |
--color-tomato, --color-sun, --color-sky, --color-mint |
"On air" dots, stars and chapter markers, topic colours |
To change the main accent, edit the lilac tokens:
@theme {
--color-lilac: #7ee0d0;
--color-lilac-soft: #b4f0e6;
--color-lilac-deep: #3fbfa8;
}A few colours are also written in data and SVG because they are drawn into artwork: the season cover colours
in SEASONS (src/data/show.ts), the occupation colours in AUDIENCE.work, the chart strokes in
src/components/Charts.tsx, the logo in src/components/Icons.tsx and the colours inside the SVG files in
public/. Echo is a dark-only design (color-scheme: dark in globals.css and in the layout's viewport
export). There is no light theme or toggle.
Typography#
The fonts are self-hosted with next/font/local in src/app/fonts.ts, from the same woff2 files Google
Fonts serves to the HTML version (src/fonts/, SIL Open Font License), so text measures exactly the same:
- Syne (600–800) for display headings, through the
displayutility and the.h-hero,.h-pageand.h-sectionclasses, and inside the SVG cover art. - Schibsted Grotesk (400–800, italic 400–600) for body text (
--font-sans). - DM Mono (400 and 500) for labels, timestamps and episode numbers (
--font-mono, the.metaclass).
Each family has a latin and a latin-ext file split by unicode-range, so names such as Mira Kovač render in
the same font. Every face keeps its plain family name (the declarations option), so CSS and SVG can say
"Syne" directly. To swap a font, put its woff2 files in src/fonts/, change the localFont() calls and
the matching stack in @theme (--font-display, --font-sans, --font-mono).
Content#
Static text (hero, hosts, reviews, press, rates, FAQ) is in each page's file, mostly in small arrays at the
top (REVIEWS, STORY, PRESS, RATES, FAQ...). Everything about episodes comes from src/data/:
| Export | File | What it holds |
|---|---|---|
SHOW |
show.ts |
Show name, the RSS feed address shown on /subscribe, and the credits line added to show notes |
TOPICS |
show.ts |
Topic ids, labels, colour dot class and a one-line description (filters, home topic list) |
SEASONS |
show.ts |
Season labels, years and the cover colours for each season |
EPISODES |
episodes.ts |
One Episode object per episode |
DETAILS |
details.ts |
Optional chapters, show notes, transcript and guest bio and links per episode id |
APPS |
site.ts |
Podcast apps: name, your show's address in the app, blurb and icon colour |
AUDIENCE |
audience.ts |
Numbers for the advertise page charts |
An episode looks like this. duration is in seconds and date is YYYY-MM-DD:
{ id: 63, season: 4, title: "The Sound of Snow", guest: "Dr. Mira Holm", role: "Snow physicist", topic: "science", date: "2026-10-21", duration: 3540,
summary: "Why fresh snow makes a city quiet, and how Mira measures it.",
quote: "Snow is a very large, very cold acoustic panel.",
src: "/audio/episode-63.mp3" },The newest date becomes the latest episode everywhere (latest() in src/lib/episodes.ts): the home
hero, the "New episode" links, /episode without an id and the subscribe page's next release date (every
other Wednesday after it). The home page's "Start with these four" list is FEATURED in src/app/page.tsx.
To give an episode real chapters, notes and a transcript, add an entry to DETAILS with its id. Times are
seconds from the start. Notes are blocks: a string is a paragraph, { h } a heading, { ul } a list and
{ links } a list of [label, href] pairs (internal links like /episode?id=62 use next/link; https:
links open in a new tab):
63: {
bio: "Mira Holm studies how snow absorbs sound at a research station in Tromsø.",
links: [["Mira’s lab", "https://example.com/snow-lab"]],
chapters: [{ t: 0, title: "Cold open" }, { t: 140, title: "Meet Mira" }, { t: 1260, title: "Measuring quiet" }],
notes: ["Paragraph of show notes.", { h: "Mentioned" }, { links: [["Episode 62: The Quietest Room on Earth", "/episode?id=62"]] }],
transcript: [[0, "Eli", "From a former piano workshop in Philadelphia, this is Overtone."], [140, "Mira", "Thanks for having me."]],
},Episodes without a DETAILS entry still get a full page: chaptersFor() spreads a standard running order
across the episode, notesFor() builds notes from the summary, chapters and related episodes, and
transcriptFor() writes a short excerpt marked as such. Replace these with your real content when you can.
Images and artwork#
There are no photos. All artwork is SVG:
- Cover art is drawn by
<CoverArt ep={ep} />insrc/components/Art.tsx: the season colour fromSEASONS, a motif for the topic (waveform for sound, orbits for science, a shape grid for design, a truss for engineering, woven scales for craft) and the episode number. Covers are square. To use your own artwork, return an<img src={...} alt="" />fromCoverArtinstead, with square images of at least 600×600 inpublic/(wrap the path inasset()fromsrc/lib/asset.ts). - Waveforms come from
bars()insrc/lib/art.tsand<WaveSVG>, seeded by the episode id, so each episode always has the same shape. They are decorative; they don't analyse audio. - Guest avatars (
<Avatar>) are the guest's initials on the season colour. - Host portraits are
public/host-nadia.svgandpublic/host-eli.svg(5:6). Replace them with photos of the same ratio, for example 800×960, and keep thealttext. - Social image:
src/app/opengraph-image.png(1200×630), rendered frompublic/og-image.svg. Edit the SVG, export it to PNG at 1200×630 and replace the PNG. The favicon issrc/app/icon.svg.
Navigation, header and footer#
The header (src/components/SiteHeader.tsx) and footer (src/components/SiteFooter.tsx) are rendered once
by the layout. The main menu is NAV in src/data/site.ts; the mobile menu adds Home and Subscribe. The
current page gets aria-current="page" from the URL (the episode page counts as Episodes). The "New
episode" links point at latest(). The "Listen on" links (src/components/ListenOn.tsx) and the footer's
app links go to the matching card on /subscribe; the cards link to APPS[].url. Replace the
https://example.com/overtone/... addresses with your real show pages.
Features#
The player#
The engine is src/lib/player.ts. It lives outside the React tree as a module-level singleton, so there is
only ever one player (and at most one <audio> element) for the whole site, and it keeps running while
Next.js swaps pages. Components read its state with usePlayer() (built on useSyncExternalStore) and
control it with the Player object: load, play, pause, toggle, seek, skip, cycleRate,
setVolume, toggleMute, unload, plus restore() and cue() (below).
The interfaces are built from the parts in src/components/player/PlayerUI.tsx. <PlayerRoot> is one
player; usePlayerUI(ep) says which episode it controls:
| Player | File | Controls |
|---|---|---|
Home hero (data-player-ui="latest") |
player/HeroPlayer.tsx |
Always the newest episode |
Episode page (#episode-player) |
EpisodePage.tsx |
The episode in the URL |
Mini player (#mini-player) |
player/MiniPlayer.tsx |
Whatever is loaded |
Inside a <PlayerRoot>, use any of <Toggle>, <Back>, <Fwd>, <Rate>, <Mute>, <Volume>,
<Close>, <Wave> (the waveform scrubber: an invisible <input type="range"> over two SVG layers),
<Cur>, <Dur> and <ProgressBar>. Pressing play anywhere pauses everything else, and every player and
play button (<PlayButton> in EpisodeCards.tsx) stays in sync. Settings: skip length is SKIP (15
seconds) and the speed cycle is RATES (1, 1.25, 1.5, 1.75, 2, 0.75).
Real audio or a simulated clock. If an episode has a src, Player.load() creates an <audio> element
for it, and the time, duration, speed, volume and end of the episode come from the real file. Episodes
without src play on a clock: a timer advances the position every 250 ms by the elapsed time multiplied by
the speed, until it reaches duration. That is how the demo plays all 62 episodes with no audio files. To
go live, add src to each episode: a file in public/ (for example /audio/episode-63.mp3; with a base
path, include it) or your podcast host's MP3 URL. Keep duration accurate: it is shown before the file has
loaded and used for chapters and the waveform.
Keyboard. Inside any player: Space or K play or pause, ←/→
or J/L skip 15 seconds, ↑/↓ change the volume and M
mutes. On the waveform slider itself the arrows move 5 seconds, Page Up/Page Down a
minute and Home/End jump to the ends. On the episode page, K, J, L and M also work
anywhere on the page while you are not typing in a field. The episode player lists the shortcuts under
"Shortcuts".
Accessibility. Every player is labelled (the episode and mini players are regions); the play button's
label says what will happen ("Play episode 62: The Quietest Room on Earth"); the seek slider announces its
position in words ("12 minutes 34 seconds of 57 minutes 56 seconds"); mute is a toggle button with
aria-pressed. Animated equaliser bars and the ripple rings around the home cover only move while playing
and stay still with reduced motion.
Mini player and saved progress#
<MiniPlayer> is rendered by src/app/layout.tsx, so it stays mounted while pages change: playback carries
on without a gap during client-side navigation. It slides up as soon as an episode is loaded, and the page
gets extra bottom padding (the has-mini class on <html>) so nothing is hidden behind it. On an episode
page it tucks away while that episode's own player is on screen (the page reports this with
Player.setPage() from an IntersectionObserver). The close button stops playback and hides it; the
position is kept.
The engine saves to localStorage every five seconds while playing, and on pause, seek and when the page is
closed (see Saved data). After a full page load or on a later visit,
Player.restore() (called once by the mini player) loads the same episode at the same second; if it was
playing it carries on (a real audio file may be blocked by the browser's autoplay rules, in which case it
waits, paused, for the visitor to press play). Episode cards and archive rows show "23 min left" or "Played"
with a progress bar: the text is worked out when the list is drawn (on page load, or when the archive's
filters change; useDrawnProgress() in EpisodeCards.tsx) and the bar follows playback.
Episode archive#
<Archive> (src/components/Archive.tsx) filters EPISODES with filterEpisodes() from
src/lib/episodes.ts: a search across title, guest, role, summary and topic (matches are highlighted), one
season, one topic and a sort (newest, oldest, longest, shortest in SORTS). The state is read from
the URL by readFilters() and written back with history.replaceState, so filtered views can be shared and
bookmarked; unknown values are ignored. Topic buttons show live counts within the current season and search.
Results show 12 at a time (PAGE_SIZE); "Show more" focuses the first new episode. Active filters appear as
removable chips, and an empty state offers to clear them. Filter buttons use aria-pressed, and the result
count is a polite live region. The static page shows the unfiltered archive; the filters from the URL apply
in the browser (<ArchiveFromUrl> inside <Suspense>).
Chapters, transcript and sharing#
/episode renders <EpisodeFromUrl>, which reads id and t from the URL (an unknown id shows "Episode not
found"), and <EpisodeView> for the episode. The exported HTML is the newest episode; the browser updates
the title and description for the episode in the URL (useDocumentMeta() in src/lib/use-document-meta.ts).
- Chapters: each is a button that seeks there and plays. The current chapter gets
aria-current="true", is named above the waveform, and appears as a dot under the waveform (<Wave chapterMarks>). - Transcript: each line is a button that seeks and plays. The current line is highlighted while you listen, and with "Follow along while playing" ticked the transcript box scrolls to keep it in view (it stops following for a few seconds after you scroll it yourself, and scrolls instantly with reduced motion). The search box filters lines and highlights matches.
- Share: the share box shows the episode link; tick "Start at" to add
&t=with the current second, then "Copy link". Opening a link with?t=cues the episode at that point, paused, with a note saying so (Player.cue(); if another episode is already playing, it keeps playing and the shared position is saved for this one).talso accepts12:34or1:02:03. Where the browser supports it, a "More" button opens the system share sheet.
Charts#
src/components/Charts.tsx draws the growth line chart and the occupation donut as inline SVG, and the age
and country charts as CSS bars, from AUDIENCE. They are Server Components, so the charts are in the static
HTML. Each SVG chart has a text alternative in aria-label, the bar lists are readable as text, and "Show
the numbers" opens a data table for the growth chart. Change the numbers in AUDIENCE and the aria-label
text together.
Header, menu, reveals and back to top#
The header turns solid after scrolling. Below 1024 px the menu button opens a full-height menu
(aria-expanded, Esc closes it and returns focus to the button; following a link closes it).
Elements with .reveal fade in on scroll (src/components/RevealObserver.tsx, which also picks up elements
added later), and revealing one also reveals everything above it; with reduced motion they are visible
straight away. The back-to-top button (src/components/BackToTop.tsx) lifts itself above the mini player
and toasts (anything marked data-back-to-top-avoid). To remove it, delete <BackToTop /> from the layout
and the .back-to-top styles in globals.css.
Forms#
All four forms are in src/components/Forms.tsx and use useValidatedForm()
(src/components/useValidatedForm.ts) with the rules in src/lib/validate.ts. Fields stay uncontrolled
(their values are read from the form), errors appear on blur and on submit, re-check as you type once shown,
and the first invalid field is focused. Each error is in the element <field id>-error, linked with
aria-describedby. On success the form is replaced by its success box, which takes focus.
| Attribute | Rule |
|---|---|
required |
Must not be empty (checkboxes must be ticked; message from data-msg) |
type="email" |
Must look like an email address |
type="url" |
Must look like a web address (example.com/work) |
minLength |
Minimum length, with how many characters to go |
data-label |
Words used in the "Enter …" / "Choose …" message |
<fieldset data-required-group data-msg="..."> |
At least one checkbox ticked |
| Form | Page | Checks |
|---|---|---|
<NewsletterForm> (#newsletter-form) |
Home | |
<SubscribeForm> (#sub-form) |
Subscribe | |
<SponsorForm> (#sponsor-form) |
Advertise | Name, company, email, optional website, at least one format, budget, message (30+ characters) |
<GuestForm> (#guest-form) |
Be a guest | Name, email, their name when "Someone else" is chosen, field, topic, optional link, story (120+ characters with a live counter), consent |
The demo forms don't send anything until you connect them; the success messages say so. To collect
submissions, post the form in onValid() in useValidatedForm.ts before showing the success state, for
example to Formspree:
async function onValid(el: HTMLFormElement) {
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(el),
headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Something went wrong. Please email us.");
// ...then the existing lines: setSent(...), el.reset()
}(toast is in src/lib/toast.ts.) Use a different endpoint per form by checking el.id. Every field has a
name, so FormData carries all values; keep a name on any field you add. For the newsletter, most email
services (Buttondown, Mailchimp, ConvertKit) accept the same fetch to their form endpoint. Then reword the
success messages, which currently say that nothing was sent.
If you drop the static export (see Building and deploying) you can post to your
own Route Handler instead, for example src/app/api/pitch/route.ts:
export async function POST(req: Request) {
const data = await req.formData();
// send an email, save to a database...
return Response.json({ ok: true, name: data.get("name") });
}Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
overtone-player |
{ "id": 18, "rate": 1.25, "volume": 0.9, "muted": false, "playing": true } |
The loaded episode and player settings. Unknown ids and speeds not in RATES are ignored on read, and the volume is clamped to 0–1. Without id, nothing is loaded |
overtone-progress |
{ "18": 1720.4, "62": 312 } |
Seconds listened per episode id. Positions within 2 seconds of the end count as played and restart from the top |
The keys are STORE_PLAYER and STORE_PROGRESS in src/lib/player.ts. Values are read with readJSON(),
which falls back to defaults on missing or broken data, and written with writeJSON(), which ignores storage
errors (private windows, full storage) so playback still works. Storage is only read in the browser, after
the page has loaded, so the static HTML never depends on it. To reset, run
localStorage.removeItem("overtone-player"); localStorage.removeItem("overtone-progress") in the browser
console.
SEO and sharing#
Each page exports its own metadata (title, description, Open Graph) with pageMeta() from
src/lib/metadata.ts; the layout adds the site name, Twitter card, theme colour and metadataBase. Set
your domain with SITE_URL when building so the social image URL is absolute:
SITE_URL=https://www.yourshow.com pnpm buildThe episode page's exported HTML describes the newest episode; the browser updates the title and description
for the episode in ?id=. If search visibility for each episode matters, generate one route per episode
instead (for example src/app/episodes/[id]/page.tsx with generateStaticParams() returning every
EPISODES id, and generateMetadata() for its title). Consider adding src/app/sitemap.ts,
src/app/robots.ts, PodcastEpisode structured data (JSON-LD) and
alternates: { types: { "application/rss+xml": SHOW.feed } } in the layout's metadata so podcast apps can
find your feed. The 404 page is noindex.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer, labelledasideand regions) and oneh1per page; the breadcrumb usesaria-current. - Visible focus rings in lilac on every control, including the invisible seek slider (the waveform gets the ring).
- The player is fully keyboard operable, with labelled buttons, a slider with spoken values and documented
shortcuts (
aria-keyshortcuts). When the mini player hides with focus inside it, focus moves to the main content. - Filters use
aria-pressed, the current chapter and transcript line usearia-current, result counts, the toast and form errors are live regions. - Reduced motion turns off scroll reveals, the ticker, equaliser and ripple animations, and smooth scrolling.
- Text colours meet WCAG AA on every surface.
When editing, keep one h1 per page, give new inputs a <label> and an error element with the matching id,
and keep alt text on replaced images.
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts). Upload out/ to any
static host: Vercel, Netlify, Cloudflare Pages, GitHub Pages or your own server. On Vercel you can also
import the project directly; it detects Next.js. Audio files can live in public/audio/ or on your podcast
host.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses
/demos/nextjs/echo):
DEMO_BASE_PATH=/podcast pnpm buildPages are exported as episodes.html, episode.html and so on; most hosts serve them at /episodes. To use
server features (Route Handlers, Server Actions, per-request rendering), remove output: "export" and
images.unoptimized from next.config.ts and deploy to a host that runs Next.js.
Troubleshooting#
- Nothing plays when I add a
src. Check the path (files inpublic/are served from/, plus the base path if you set one), that the file is MP3, AAC or another format the browser plays, and that your audio host allows it (some hosts block hotlinking). Errors show a toast. - The player keeps resuming an old episode. That is the saved state. Clear it with the commands in Saved data, or press the mini player's close button.
- "Copy link" doesn't copy. The clipboard API needs
httpsorlocalhost; elsewhere the template falls back to selecting the link so it can be copied by hand. useSearchParams() should be wrapped in a suspense boundarywhen building. Components that read the URL (<ArchiveFromUrl>,<EpisodeFromUrl>,<FormatPreselect>) must stay inside<Suspense>in their page./episode?id=...works locally but 404s on my host. The host must serveepisode.htmlfor/episode. Most do; on others link to/episode.html?id=...or add a rewrite.- Times or chapters are wrong after replacing audio. Update
durationand the chaptertvalues inDETAILS, all in seconds.
License#
MIT. Free for personal and commercial use, including client work. The full text is in LICENSE.txt in the
download. All artwork (cover art, waveforms, portraits and icons) is part of the template and covered by the
same license; the fonts are under the SIL Open Font License (src/fonts/OFL.txt).