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 Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. The design is dark-first: a blackcurrant night background, wide Syne headlines, mono labels and colorful 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.
pnpm install
pnpm devOpen http://localhost:3000. Edits to pages, components and data reload instantly.
pnpm install also runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint use.
| Script | What it does |
|---|---|
pnpm dev |
Development server |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages are still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint |
pnpm typecheck |
nuxt prepare and vue-tsc |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
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 |
app/pages/episodes.vue |
Archive: search, season and topic filters, sort, filter chips, load more, empty state (/episodes?topic=sound&season=4&q=room&sort=oldest) |
/episode |
app/pages/episode.vue |
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 |
app/pages/hosts.vue |
Host cards, numbers, timeline, how an episode is made, press quotes (#press), team |
/subscribe |
app/pages/subscribe.vue |
App links (#apple-podcasts, #spotify, #youtube, #pocket-casts, #overcast, #rss), copyable RSS feed, next release date, FAQ, email sign-up |
/sponsor |
app/pages/sponsor.vue |
Audience stats, growth line chart, occupations donut, age and country bars, ad formats, rate card (#rates), inquiry form (#enquire; /sponsor?format=season checks a format) |
/guest |
app/pages/guest.vue |
What we look for, what happens next, pitch form |
| 404 | app/error.vue |
"Dead air" page with links back into the site, generated as 404.html |
Project structure#
echo/
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
app/
app.vue the layout and the current page (keyed so a link to the open page restarts it)
layouts/default.vue skip link, header, the page, footer, mini player, toast, back to top
pages/ one file per page (index.vue, episodes.vue, episode.vue, hosts.vue, ...)
error.vue the 404 page
components/
HeroPlayer.vue the home page player (always the newest episode)
MiniPlayer.vue the mini player docked to the bottom of every page
Player*.vue player controls: PlayerToggle, PlayerWave, PlayerRate, PlayerVolume, ...
EpisodeView.vue the episode page: player, chapters, notes, transcript, guest, share
EpisodeMissing.vue "Episode not found"
EpCard.vue, EpRow.vue episode cards and archive rows; PlayButton.vue is their play button
CoverArt.vue, WaveSvg.vue, GuestAvatar.vue SVG artwork drawn from the episode data
GrowthChart.vue, WorkDonut.vue, BarList.vue, ChartTable.vue advertise page charts
NewsletterForm.vue, SubscribeForm.vue, SponsorForm.vue, GuestForm.vue the four forms
SiteHeader.vue, SiteFooter.vue, ListenOn.vue, SiteToast.vue, BackToTop.vue, icons
composables/
usePlayerUI.ts one player's state and keyboard handling (usePlayerRoot), shared by its controls
useEpisodeProgress.ts saved progress as drawn on cards and rows
useValidatedForm.ts form validation and success states
usePageSeo.ts, useCurrentPage.ts, useAsset.ts
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)
utils/
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, safeStorage.ts
plugins/ scroll reveals, same-page links, the player's base path, stable builds
assets/css/main.css Tailwind CSS: fonts, design tokens (@theme) and component classes
assets/fonts/ woff2 font files and their license (OFL.txt)
public/img/ favicon, host portraits, social image (og-image.png) and its SVG source
template.json, README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
The design tokens live in the @theme block of app/assets/css/main.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 colors |
To change the main accent, edit the lilac tokens:
@theme {
--color-lilac: #7ee0d0;
--color-lilac-soft: #b4f0e6;
--color-lilac-deep: #3fbfa8;
}A few colors are also written in data and SVG because they are drawn into artwork: the season cover colors
in SEASONS (app/data/show.ts), the occupation colors in AUDIENCE.work, the chart strokes in
app/components/GrowthChart.vue and WorkDonut.vue, the logo in app/components/LogoMark.vue and the
colors inside the SVG files in public/img/. Echo is a dark-only design (color-scheme: dark in
main.css and in the color-scheme and theme-color tags in nuxt.config.ts). There is no light theme or
toggle.
Typography#
The fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of
app/assets/css/main.css. They are the same woff2 files Google Fonts serves to the HTML version (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. To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and
the matching stack in @theme (--font-display, --font-sans, --font-mono).
Content#
Static text (hero, hosts, reviews, press, rates, FAQ) is written in each page's template. Everything about
episodes comes from app/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, color dot class and a one-line description (filters, home topic list) |
SEASONS |
show.ts |
Season labels, years and the cover colors 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 |
NAV, APPS, CONTACT |
site.ts |
Main menu, podcast apps (name, your show's address in the app, blurb), email addresses |
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 app/utils/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 START_HERE in
app/pages/index.vue.
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 <NuxtLink>;
https: links open in a new tab):
63: {
bio: "Mira Holm studies how snow absorbs sound at a research station in Fairbanks, Alaska.",
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" />(app/components/CoverArt.vue): the season color 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, render an<img :src="asset(...)" alt="">inCoverArt.vueinstead, with square images of at least 600×600 inpublic/(useAsset()adds the base path). - Waveforms come from
bars()inapp/utils/art.tsand<WaveSvg>, seeded by the episode id, so each episode always has the same shape. They are decorative; they don't analyze audio. - Guest avatars (
<GuestAvatar>) are the guest's initials on the season color. - Host portraits are
public/img/host-nadia.svgandpublic/img/host-eli.svg(5:6). Replace them with photos of the same ratio, for example 800×960, and keep thealttext. - Social image:
public/img/og-image.png(1200×630), rendered frompublic/img/og-image.svg. Edit the SVG, export it to PNG at 1200×630 and replace the PNG. The favicon ispublic/img/favicon.svg.
Navigation, header and footer#
The header (app/components/SiteHeader.vue) and footer (app/components/SiteFooter.vue) are rendered once
by the layout. The main menu is NAV in app/data/site.ts; the mobile menu adds Home and Subscribe. The
current page gets aria-current="page" from useCurrentPage() (the episode page counts as Episodes). The
"New episode" links point at latest(). The "Listen on" links (app/components/ListenOn.vue) and the
footer's app links go to the matching card on /subscribe; the cards on app/pages/subscribe.vue link to
your show in each app. Replace the https://example.com/overtone/... addresses there and in APPS.
To add a page, create app/pages/<name>.vue (copy hosts.vue for the <main> element and usePageSeo()),
add its path to routes in nuxt.config.ts so it is generated, and add it to NAV or the footer.
Features#
The player#
The engine is app/utils/player.ts. It lives outside the component 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 Nuxt swaps pages. Components render from playerSnapshot (a shallowRef replaced on every change)
and control it with the Player object: load, play, pause, toggle, seek, skip, cycleRate,
setVolume, toggleMute, unload, plus restore() and cue() (below).
Each player calls usePlayerRoot() from app/composables/usePlayerUI.ts with the episode it controls and
puts its onKeydown on its root element:
| Player | File | Controls |
|---|---|---|
Home hero (data-player-ui="latest") |
HeroPlayer.vue |
Always the newest episode |
Episode page (#episode-player) |
EpisodeView.vue |
The episode in the URL |
Mini player (#mini-player) |
MiniPlayer.vue |
Whatever is loaded |
Inside a player, use any of <PlayerToggle>, <PlayerBack>, <PlayerFwd>, <PlayerRate>,
<PlayerMute>, <PlayerVolume>, <PlayerClose>, <PlayerWave> (the waveform scrubber: an invisible
<input type="range"> over two SVG layers; size="sm" or "lg", chapter-marks), <PlayerCur>,
<PlayerDur> and <PlayerProgressBar>; they find their player with usePlayerContext(). Pressing play
anywhere pauses everything else, and every player and play button (<PlayButton>) 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), both in
player.ts.
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/ written without a leading slash (for example
audio/episode-63.mp3; the site's base path is added for you) or your podcast host's full 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 labeled (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 equalizer 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 app/layouts/default.vue, so it stays mounted while pages change: playback
carries on without a gap. 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 when it mounts) 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 app/composables/useEpisodeProgress.ts) and the bar
follows playback. Nothing is read from storage until the page has loaded, so the generated HTML hydrates
without mismatches.
Episode archive#
app/pages/episodes.vue filters EPISODES with filterEpisodes() from app/utils/episodes.ts: a search
across title, guest, role, summary and topic (matches are highlighted by <MarkText>), 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 generated page shows the unfiltered archive; the filters from the URL
apply once the page has loaded (or straight away after a link click, such as a topic on the home page).
Chapters, transcript and sharing#
app/pages/episode.vue reads id and t from the URL (an unknown id shows <EpisodeMissing>) and renders
<EpisodeView> for the episode. The generated HTML is the newest episode; the browser updates the title and
description for the episode in the URL.
- 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 (<PlayerWave chapter-marks>). - Transcript: each line is a button that seeks and plays. The current line is highlighted while you listen, and with "Follow along while playing" checked 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; check "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#
GrowthChart.vue and WorkDonut.vue draw the growth line chart and the occupation donut as inline SVG, and
BarList.vue the age and country charts as CSS bars, from AUDIENCE. They are part of the generated 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 (ChartTable.vue) 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 or widening the window
closes it). Elements with .reveal fade in on scroll (app/plugins/reveal.client.ts, 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 (app/components/BackToTop.vue) 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 main.css.
Links to the page you are on#
In the HTML version every link loads a page. Here links are client-side navigations, so
app/plugins/same-page.client.ts and the page key in app/app.vue make a link to the page already showing
(Episodes on a filtered archive, the newest episode on its own page) start that page afresh at the top, and
another episode on the episode page (?id=) opens from the top. experimental.navigationRepaint is off in
nuxt.config.ts, so a link opens the next page straight away instead of after Nuxt's 100 ms repaint pause.
Forms#
All four forms are components (NewsletterForm.vue, SubscribeForm.vue, SponsorForm.vue,
GuestForm.vue) that use useValidatedForm() (app/composables/useValidatedForm.ts) with the rules in
app/utils/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 checked; 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 checked |
| 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 forms don't send anything until you connect them, although their success messages read as if they
had. 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: sent.value = ..., el.reset()
}(toast is in app/utils/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 check
the success messages in the form components match what really happens: the newsletter ones promise a
confirmation email, the sponsor one a reply from Rosa within two business days and the guest one a reply
within three weeks. The sponsor and guest messages show a short reference (OT- and four characters) made by
makeRef() in useValidatedForm.ts; show your form service's own reference instead if it returns one.
If you deploy with pnpm build to a host that runs Node.js, you can post to your own server route instead,
for example server/api/pitch.post.ts:
export default defineEventHandler(async (event) => {
const data = await readFormData(event);
// send an email, save to a database...
return { 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 app/utils/player.ts. Values are read with
readJSON(), which falls back to defaults on missing or broken data, and written with writeJSON(). Both
go through safeStorage (app/utils/safeStorage.ts), which keeps the values in memory when the browser
blocks storage or it is full, so playback and progress still work for the page view. To reset, run
localStorage.removeItem("overtone-player"); localStorage.removeItem("overtone-progress") in the browser
console.
SEO and sharing#
Each page calls usePageSeo() (app/composables/usePageSeo.ts) with its title and description, which adds
Open Graph and Twitter tags, a canonical link and an absolute og:image. Site-wide tags (favicon,
theme-color, color-scheme) are in app.head in nuxt.config.ts. Set your domain with SITE_URL when
building so canonical and social image URLs point at it:
SITE_URL=https://www.yourshow.com pnpm generateThe episode page's generated 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 app/pages/episodes/[id].vue, with every id listed in routes in
nuxt.config.ts). Consider adding a sitemap and robots.txt (in public/), PodcastEpisode structured
data (JSON-LD with useHead()) and a <link rel="alternate" type="application/rss+xml"> to SHOW.feed in
app.head so podcast apps can find your feed. The 404 page is noindex.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer, labeledasideand 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 labeled 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, equalizer and ripple animations, and smooth scrolling.
- Text colors 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 generate writes a static site to .output/public/: every page in routes (nuxt.config.ts) as
episodes.html, episode.html and so on, plus 404.html. Upload that folder to any static host: Netlify,
Vercel, Cloudflare Pages, GitHub Pages or your own server. Most hosts serve episodes.html at /episodes.
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/nuxt/echo); links, assets, fonts and audio files in public/ all get the prefix:
DEMO_BASE_PATH=/podcast pnpm generatepnpm build makes a Node.js server build in .output/ instead (start it with
node .output/server/index.mjs), which you need for server routes like the form example above.
TypeScript setup#
tsconfig.json only references the configs Nuxt writes to .nuxt/, so run pnpm install (or
pnpm typecheck) once before your editor or ESLint can check the project.
Troubleshooting#
- Nothing plays when I add a
src. Check the path (files inpublic/are written without a leading slash, likeaudio/episode-63.mp3), 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. - A new page is missing from the generated site. Add its path to
routesinnuxt.config.ts; pages are not discovered by crawling links. /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. - ESLint says
.nuxt/eslint.config.mjsis missing. Runpnpm installorpnpm typecheckfirst; both runnuxt prepare.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Schibsted
Grotesk, Syne and DM Mono are under the SIL Open Font License (app/assets/fonts/OFL.txt). The show, its
hosts, guests, listeners and reviews are fictional.