Overview#
Gather is a website for a local church, built for Riverbend Church, a fictional non-denominational church in
Lawrence, Kansas. It has a home page with this Sunday's times and a countdown to the next service, a
plan-your-visit page, a sermon archive and sermon pages with an audio player, an events calendar with calendar
downloads, a small-group finder and ministries, a giving page, an about page with the staff team, contact and
prayer requests, and a 404 page. Every illustration, portrait and series cover is SVG, so there are no photos to
replace. This is the Nuxt 4 version (Vue 3, TypeScript, Tailwind CSS v4, generated as a static site),
1.0.0. Sermons, events, groups, giving and staff live in typed data files in app/data/.
Live demo: freeframe.dev/templates/gather
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000| Script | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint flat config) |
pnpm typecheck |
nuxt prepare, then vue-tsc |
pnpm build:demo |
Build for the freeframe.dev demo (base path /demos/nuxt/gather) into demo/ |
Add ?live=1 to any page (for example /?live=1) to see the "Live now" state of the countdown.
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with river illustration, "This Sunday" card with times and <CountdownBox>, three-step "I'm new" path, latest sermon with <AudioPlayer>, the next four events (with <EventDialog>), ministries, scripture and figures band, <NewsletterForm> |
/visit |
app/pages/visit.vue |
What to expect, service times, kids' check-in steps and rooms, accessibility, SVG parking map, FAQ accordion, <VisitForm> with a live summary |
/sermons |
app/pages/sermons.vue |
"Watch live" panel with a countdown and the latest message, then <SermonArchive>: series cards, search and series/speaker/book filters kept in the URL, sermon cards |
/sermon |
app/pages/sermon.vue |
One sermon (/sermon?id=room-at-the-table): series cover, audio player, key verse, notes, discussion questions with a copy button, speaker card, the rest of the series, related sermons |
/events |
app/pages/events.vue |
<EventsCalendar>: category filter, month calendar and list views, selected-day agenda; <EventDialog> with "Add to calendar" (.ics) |
/groups |
app/pages/groups.vue |
<GroupFinder> (day, area, season of life, childcare), ministries for kids, youth, care and music (#kids, #youth, #care, #music) |
/give |
app/pages/give.vue |
<GiveForm>: frequency, amount chips and your own amount, fund, cover-the-fee option, live total; budget bar, funds, FAQ |
/about |
app/pages/about.vue |
Story timeline, beliefs (#beliefs), leadership, staff grid with SVG portraits |
/contact |
app/pages/contact.vue |
Office details, urgent pastoral contact, <ContactForm>, <PrayerForm> (#prayer) |
| 404 | app/error.vue |
Signpost illustration and links back (generated as 404.html) |
Project structure#
gather/
├── nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── package.json scripts and dependencies
├── eslint.config.mjs ESLint flat config (@nuxt/eslint)
├── public/img/ favicon.svg, og-image.png (social image) and og-image.svg (its source)
└── app/
├── app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, keyed so links to the same page restart it
├── error.vue the 404 page
├── router.options.ts writes query strings like the HTML version
├── layouts/default.vue skip link, header, page, footer, back-to-top button
├── pages/ one file per page (see Pages)
├── components/
│ ├── SiteHeader.vue top bar, sticky header, main and mobile menus
│ ├── ServiceStatus.vue "Next gathering … / Live now" line in the top bar
│ ├── CountdownBox.vue countdown to the next service / "Live now" card
│ ├── AudioPlayer.vue the sermon player
│ ├── SermonArchive.vue series cards, filters and sermon list (/sermons)
│ ├── SermonCard.vue a sermon in a grid
│ ├── EventsCalendar.vue month grid, day agenda and list view (/events)
│ ├── EventCard.vue an event date as a card button
│ ├── EventDialog.vue the event detail dialog and .ics download
│ ├── GroupFinder.vue group filters and cards (/groups)
│ ├── NewsletterForm.vue, VisitForm.vue, GiveForm.vue, ContactForm.vue, PrayerForm.vue
│ ├── FieldError.vue, SuccessBox.vue a field's error message, a form's thank-you box
│ ├── StaffPortrait.vue, SeriesArt.vue SVG staff portraits and series covers
│ └── SiteLogo.vue, SiteFooter.vue, AppIcon.vue, BackToTop.vue
├── composables/
│ ├── useValidation.ts form validation (see Forms)
│ ├── useServiceState.ts, useThisSunday.ts the next or current service, this Sunday's date
│ ├── useEventDialog.ts opens the event dialog from any card
│ ├── usePageSeo.ts, useCurrentPage.ts, useMounted.ts, useTick.ts, useYear.ts
├── data/
│ ├── church.ts CHURCH, SERVICES, KEEP_DEMO_CURRENT, DEMO_ANCHOR
│ ├── people.ts PEOPLE, STAFF
│ ├── sermons.ts SERIES, BOOKS, SERMONS, PROGRESS_KEY
│ ├── events.ts EVENT_CATS, EVENTS
│ ├── groups.ts DAYS, AREAS, STAGES, GROUPS
│ ├── giving.ts GIVING
│ ├── visit.ts VISIT
│ └── site.ts NAV, MOBILE_NAV
├── utils/
│ ├── dates.ts day numbers, formatting and the church's time zone
│ ├── demo.ts calendar(): shifted sermon and event dates, every event occurrence
│ ├── services.ts serviceState(): the next or current service
│ ├── ics.ts buildIcs() and downloadIcs()
│ ├── progress.ts saved listening positions
│ ├── art.ts avatarSvg() and seriesArtShapes(): the SVG artwork
│ ├── forms.ts fieldMessage(), makeRef(), money()
│ └── url.ts, safeStorage.ts
├── plugins/ scroll reveals, links to the same page, stable builds
└── assets/
├── css/main.css Tailwind entry: fonts, brand tokens (@theme) and component classes
└── fonts/ the woff2 files and their licensesComponents, composables and utilities are imported automatically; data files are imported by path
(import { SERMONS } from "~/data/sermons").
Customizing#
Brand and colors#
The design tokens are in the @theme block of app/assets/css/main.css. Every utility such as bg-plum or
text-muted comes from them.
| Token | Default | Used for |
|---|---|---|
--color-paper |
#fbf6ee |
Page background |
--color-shell |
#fffdf9 |
Cards and form fields |
--color-sand |
#f4eadb |
Soft panels, filter bars |
--color-line |
#e5d8c5 |
Hairlines and card rings |
--color-ink |
#2b1b36 |
Text, the footer, dark bands and the audio player |
--color-muted |
#5f5468 |
Secondary text |
--color-plum / --color-plum-dark |
#5b2a6e / #431d52 |
Brand color, primary buttons, links |
--color-lilac |
#efe5f5 |
Selected states, worship events |
--color-apricot / -light / -dark |
#f6a96b / #fde3cc / #9a4716 |
Warm accent (backgrounds), tint, accent text |
--color-sage / -light / -dark |
#c9dcc0 / #e6efe0 / #33603b |
Hills, care ministry, serve events, success |
--color-river / -light / -dark |
#bcd9e8 / #e3f0f6 / #1f5577 |
River, kids' ministry and events |
--color-rose |
#b3364a |
Errors, "Live now", classes events |
To change the brand color, edit the tokens; the dev server picks the change up at once:
@theme {
--color-plum: #1f5c7a;
--color-plum-dark: #164459;
}Use apricot only as a background: for text on light backgrounds use apricot-dark, which passes WCAG AA. The SVG
artwork (hero, map, portraits, series covers, logo) uses literal hex values; search app/ for #5b2a6e and
#f6a96b to recolor it. Also change theme-color in app.head in nuxt.config.ts if you change the page
background.
Component classes in main.css: .btn-primary, .btn-warm, .btn-outline, .btn-light, .btn-sm, .card,
.card-hover, .eyebrow, .h-display, .h-section, .tag, .chip-dot, .link, .field, .field-label,
.field-error, .option and .pill (radio and checkbox cards that highlight when checked), .check, .faq (the
accordion) and .scrub (the player's slider).
Typography#
Urbanist (headings, font-display) and Nunito Sans (body text, font-sans) are self-hosted from
app/assets/fonts/: the variable Latin woff2 files Google Fonts serves (SIL Open Font License), declared with
@font-face at the top of main.css. They keep their real family names, so the tokens and the SVG text
(font-family="Urbanist, sans-serif") use them directly:
--font-sans: "Nunito Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Urbanist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the names in
@theme.
Content#
Page copy is in the page files in app/pages/. Everything that is listed, filtered or calculated comes from
app/data/:
| Export | File | What it holds |
|---|---|---|
CHURCH |
church.ts |
Name, timeZone (IANA name, e.g. America/Chicago), timeZoneLabel, full address for calendar files, liveUrl, uidDomain |
SERVICES |
church.ts |
The weekly schedule: day (0 = Sunday), start ("09:00"), minutes, name |
KEEP_DEMO_CURRENT, DEMO_ANCHOR |
church.ts |
Moves demo dates forward (see Demo dates) |
PEOPLE, STAFF |
people.ts |
Staff and speakers with role, email, bio and avatar; STAFF is the order on the about page |
SERIES, BOOKS, SERMONS |
sermons.ts |
Sermon series (title, subtitle, art: table, sun, wheat or wave, blurb), book labels, every sermon |
EVENT_CATS, EVENTS |
events.ts |
Event categories with their color classes, and events |
DAYS, AREAS, STAGES, GROUPS |
groups.ts |
Small-group filter labels and groups |
GIVING |
giving.ts |
Amount chips, default amount, min and max, card fee, funds |
VISIT |
visit.ts |
Number of Sundays offered, maximum party size, kids' rooms by age |
NAV, MOBILE_NAV |
site.ts |
Menu items |
Add a sermon to SERMONS (order doesn't matter; lists sort by date). TypeScript checks the keys:
{
id: "a-new-sermon", title: "A New Sermon", series: "open-table", part: 6, speaker: "dana",
date: "2026-10-18", book: "luke", passage: "Luke 10:38–42", duration: 2100, audio: "",
summary: "One or two sentences for cards and the page description.",
verse: { text: "Quoted text.", ref: "Luke 10:41 (KJV)" },
notes: [["Heading", "One short paragraph."], ["Heading", "Paragraph."]],
questions: ["First question?", "Second question?"],
},speaker is a PersonId (a key of PEOPLE), series a SeriesId, book a key of BOOKS, and duration is in
seconds. A new person or series also needs its id added to the PersonId or SeriesId type. The demo quotes the
King James Version, which is in the public domain; check the license of any other translation before quoting it.
Add an event to EVENTS. Times are 24-hour, in the church's time zone; add weekly to repeat it every week until
that date:
{ id: "choir-night", title: "Choir Night", cat: "worship", date: "2026-11-05", weekly: "2026-12-17",
start: "19:00", end: "20:30", place: "Main auditorium", summary: "Open rehearsal for anyone who sings." },Add a group to GROUPS; day, area and stage must be keys of DAYS, AREAS and STAGES, and spots: 0
shows "Full: join the waitlist":
{ id: "lunch-break", name: "Lunch Break", day: "wed", time: "12:15", area: "downtown", stage: "everyone",
host: "Ana Ruiz", where: "Riverbend, Room 210", spots: 6, childcare: false, blurb: "A 45-minute study over lunch." },Demo dates#
The demo is written for the week of DEMO_ANCHOR ("2026-10-11"). While KEEP_DEMO_CURRENT is true, every
sermon and event date moves forward by whole weeks so that the latest sermon is always last Sunday and events are
always upcoming. Weekdays never change. Set KEEP_DEMO_CURRENT = false in app/data/church.ts once you enter
real dates, or your dates will be shifted too.
The shift is worked out by calendar() in app/utils/demo.ts, in the browser, from today's date in the church's
time zone. Anything that depends on the clock or the address (dates, the countdown, "Live now", the calendar,
sermon lists, filters read from the URL) is rendered once the page is running in the browser (in onMounted(), or
behind useMounted()), so the generated HTML never holds a stale date and hydration never mismatches.
Images and artwork#
There are no photos. All artwork is SVG:
| Artwork | Where |
|---|---|
| Hero landscape (church, river, hills) | Inline in app/pages/index.vue, 600×540 |
| Welcome tent, small-group table, 404 signpost | Inline in the visit and groups pages and app/error.vue |
| Parking map | Inline in app/pages/visit.vue, 640×420, with a <title> and <desc> |
| Staff portraits | <StaffPortrait as="span" :svg="avatarSvg('dana')" />, drawn by avatarSvg() in app/utils/art.ts from each person's avatar settings |
| Series covers | <SeriesArt series="ruth" :v="2" />, 320×200, varied by v (the sermon's part) |
| Logo | app/components/SiteLogo.vue, and public/img/favicon.svg |
A portrait's avatar object takes skin, hair, style (short, long, bun, curly, coily, waves or
bald), bg, shirt, and optional glasses and beard. avatarSvg(id) is decorative; avatarSvg(id, label)
is an image with that alt text.
To use photos instead, replace <StaffPortrait> or <SeriesArt> with <img> tags (files in public/, with
$config.app.baseURL in front of the path): portraits are square (1:1), series covers 16:10, and the hero frame
about 10:9. Give every image meaningful alt text.
The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. Edit the SVG, then export it
to PNG at 1200×630.
Navigation, header and footer#
The header and its menus are app/components/SiteHeader.vue, built from NAV and MOBILE_NAV in
app/data/site.ts; the footer is app/components/SiteFooter.vue. To add a page, create app/pages/<route>.vue, add
its route to routes in nuxt.config.ts and add it to NAV (and to the footer lists if you like). The current
page's link gets aria-current="page" (useCurrentPage()); /sermon counts as Sermons.
Features#
Service status and countdown#
<ServiceStatus> in the top bar shows "Next gathering: Sunday at 9:00 am", or "Live now" with a "Watch" link while
a service is running. <CountdownBox> (home page and the sermons page; dark for the plum panel) shows days,
hours, minutes and seconds to the next service, and switches to a "Live now" card with a "Watch live" button
linking to CHURCH.liveUrl. useThisSunday() gives the date of the next Sunday with a service still to come.
Everything comes from serviceState() in app/utils/services.ts, which reads SERVICES in CHURCH.timeZone, so
a visitor in another time zone sees the right countdown for your church. Add a service to SERVICES:
{ day: 3, start: "18:30", minutes: 60, name: "Midweek Worship" },To preview the live state at any time, add ?live=1 to a URL. The ticking numbers are hidden from screen readers;
a visually hidden sentence ("Starts in 5 days, 18 hours and 53 minutes.") gives the same information without
announcing every second. Before the page is running in the browser, these show the plain service times.
Audio player#
<AudioPlayer :sermon="s" /> appears on the sermon page, on the home page (latest sermon) and in the "Watch live"
panel on /sermons.
- With a real file: set the sermon's
audioto a URL ("/audio/room-at-the-table.mp3"for a file inpublic/audio/, or a full URL from your podcast host). The player then drives a real<audio>element: its duration comes from the file, and speed, seeking and progress use the media element. - Without a file (
audio: "", the demo): playback is simulated over the sermon'sdurationwith a timer, so every control works and a note under the player says so.
Controls: play/pause, a slider for scrubbing (5-second steps), back and forward 15 seconds, and a speed button cycling through 1×, 1.25×, 1.5×, 1.75×, 2× and 0.75×. Only one player plays at a time.
Keyboard, while focus is inside the player: Space (when not on a button) or K play/pause, J and L back and
forward 15 seconds, arrow keys on the slider move 5 seconds, Home/End jump to the start or end. The slider has
an aria-valuetext such as "12 minutes 5 seconds of 38 minutes 42 seconds", the play button's label switches
between "Play" and "Pause", and speed changes are announced through a polite live region.
The listening position is saved (see Saved data); returning to a sermon shows "Resuming at 12:04" and sermon cards show "Resume at 12:04".
Sermon archive#
<SermonArchive> fills the series, speaker and book selects from the data and filters on every change. Search
(after a short pause in typing, or on Enter) matches every word against the title, passage, summary, series and
speaker. The filters are kept in the URL, so links can be shared:
/sermons?series=open-table&speaker=dana&book=luke&q=table (names are case-insensitive; unknown values are
ignored). Series cards toggle the series filter. The result count is a live region; with no results an empty state
offers to clear the filters.
Sermon page#
/sermon?id=<id> shows one sermon; an unknown or missing id shows the latest sermon, with a notice for an unknown
one. The page title and meta description follow the sermon. "Copy questions" copies the title, passage and
numbered questions to the clipboard (it needs https or localhost; elsewhere it says copying failed). Related
sermons are the rest of the series, then other sermons by the same speaker. Moving to another sermon starts the
page again from the top.
Events calendar#
<EventsCalendar> shows every occurrence of EVENTS (weekly events are expanded by calendar()). The month view
is a table with one button per day; choosing a day shows its events in the "Selected day" panel, with the next three
events after it. On wide screens each day lists up to two events and a "+n more" button. The list view shows weekly
events once in an "Every week" box, then upcoming events by month.
Category pills filter both views. State is kept in the URL: /events?view=list&cat=kids,serve&month=2026-11.
Opening an event adds event=<id>-<date>, so a link opens straight to that event.
The detail is <EventDialog>, a native <dialog> (focus stays inside it and Esc closes it; focus returns to the
button that opened it). <EventCard> and the calendar open it with useEventDialog().open(occurrence, button).
"Add to calendar" downloads an .ics file built by buildIcs() in app/utils/ics.ts, with start and end times
converted from the church's time zone to UTC, so it imports correctly into Google Calendar, Apple Calendar and
Outlook wherever the visitor is.
Groups finder#
<GroupFinder> renders filter pills for day, area and season of life from DAYS, AREAS and STAGES, each with a
live count; options that would give no results are disabled. "Childcare provided" is a checkbox. State is in the
URL: /groups?day=tue,thu&area=west&stage=families&childcare=1. "Ask to join" links to
/contact?topic=groups&group=<id>, which pre-fills the contact form.
Plan your visit#
<VisitForm> offers the next VISIT.sundays Sundays (computed in the church's time zone; today is skipped once the
last Sunday service has started) and one radio card per Sunday service. Adults and children use steppers (typed
numbers are kept within limits when the field changes); each child gets an age select, and the summary shows which
room they go to from VISIT.rooms. Children in the last room (youth) count as an extra seat.
Giving#
<GiveForm> builds the amount chips from GIVING.amounts and the fund select from GIVING.funds. Typing your own
amount clears the chips. "Cover the card fee" adds what is needed so that, after the fee in GIVING.fee (2.2% +
$0.30 by default), the church receives the whole gift (feeFor()); the summary shows the total and what the church
receives either way. /give?fund=pantry pre-selects a fund.
No payment is taken. See Connecting a payment provider.
Scroll reveal, header and back to top#
Elements with .reveal fade in as they scroll into view (app/plugins/reveal.client.ts); everything above the
current element is revealed too, so jumping to an anchor never leaves gaps. A head script adds the js class to
<html>, so content stays visible without JavaScript, and with reduced motion nothing is hidden or animated. Never
put .reveal on an element that also has a :class binding: Vue would drop the is-visible class the plugin adds.
The header gets a border and shadow once the page scrolls. The mobile menu (#menu-toggle) closes with Esc,
after following a link and when the window grows to desktop width. The back-to-top button (BackToTop.vue)
appears after about one screen, scrolls up (instantly with reduced motion) and moves focus to the main content; it
hides while it would cover the sticky gift and visit summaries (data-back-to-top-hide). To remove it, delete
<BackToTop /> from app/layouts/default.vue and the .back-to-top styles in main.css.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, so a link to the page already
showing (Sermons on a filtered sermon archive, a footer link to the current page) restarts the page from the top
(app/plugins/same-page.client.ts and the page key in app/app.vue), and filters that write the URL themselves
(setUrl() in app/utils/url.ts) keep the router's history state.
Forms#
All forms use useValidation() (app/composables/useValidation.ts): errors appear on blur and submit and update as
you type, the first invalid field is focused, each error (<FieldError>) is linked with aria-describedby, and a
status line says "Please fix the highlighted fields." The rules come from each field's attributes:
| Attribute | Rule |
|---|---|
required |
Must not be empty (checkboxes must be checked, selects must have a value) |
type="email", type="tel" |
Must look like an email address / phone number |
minlength |
Minimum length |
data-label |
Name used in the error message |
data-novalidate |
Field is skipped (used by filters) |
| Form | Component | Behavior |
|---|---|---|
Newsletter (data-form="newsletter") |
NewsletterForm.vue (home) |
Replaced with a thank-you message that points to a confirmation email |
Plan your visit (#visit-form) |
VisitForm.vue |
Validates contact details and each child's age; confirms the Sunday, time and seats, with a reference |
Giving (#give-form) |
GiveForm.vue |
Validates the amount (min and max from GIVING), name and email; confirms the gift, fund and frequency, with a reference |
Contact (#contact-form) |
ContactForm.vue |
Pre-filled from ?topic= and ?group=; thank-you message with a reference |
Prayer (#prayer-form) |
PrayerForm.vue |
Request required; email becomes required when "I'd like a pastor to get in touch" is checked; the message reflects "Keep this private" |
The forms don't send anything until you connect them, although their success messages read as if they had. The
visit, giving and contact messages show a short reference (RC- and four characters) made in the browser by
makeRef() in app/utils/forms.ts; show your form service's or payment provider's own reference instead if it
returns one. Check that the promises in the messages match how your church works: a reminder email before Sunday,
an emailed receipt for every gift, a reply within two business days (as the contact page says), and a confirmation
email for newsletter sign-ups. The callback passed to useValidation() in each form is where to send it. For
example, with Formspree in ContactForm.vue:
const v = useValidation(async (form) => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
const name = (form.querySelector<HTMLInputElement>("#c-name")?.value ?? "").trim().split(" ")[0] ?? "";
if (res.ok) done.value = { name, ref: makeRef("RC") };
});Every field has a name, so FormData carries all the values; keep a name on any field you add. Prayer requests
can be sensitive: send them to a service and inbox only your pastoral team can read, and honor the private field.
With pnpm build (a Node.js server), you can post to a server route of your own instead, for example
server/api/contact.post.ts:
export default defineEventHandler(async (event) => {
const data = await readFormData(event);
// Send data.get("email"), data.get("message")… to your inbox or CRM here.
return { ok: true };
});Connecting a payment provider#
The giving form never collects card details. To take real gifts, change the callback in GiveForm.vue to call your
server, which creates a checkout session with your provider (Stripe Checkout, PayPal, or a church platform such as
Tithe.ly or Planning Center Giving) and returns its URL:
const res = await fetch("/api/create-gift", { method: "POST", body: new FormData(form) });
const { url } = await res.json();
window.location.href = url;That endpoint needs a server (a server route with pnpm build, or a function on your host). Recurring gifts
(frequency is weekly or monthly) map to subscriptions in most providers. Many church platforms also offer an
embeddable giving form you can use instead of this one.
Saved data (localStorage)#
| Key | Shape | Used for |
|---|---|---|
riverbend-sermon-progress (PROGRESS_KEY) |
{ "room-at-the-table": 724 } (seconds) |
Resume position for each sermon |
readProgress() in app/utils/progress.ts keeps only ids that exist in SERMONS and positions between 5 seconds
and 5 seconds before the end; anything else (broken JSON, arrays, unknown ids) is dropped. A position is removed
when a sermon finishes. Storage goes through safeStorage (app/utils/safeStorage.ts): if the browser blocks it
(private modes, sandboxed frames) or it is full, positions are kept in memory for the page view instead. To reset,
run localStorage.removeItem("riverbend-sermon-progress") in the browser console, or change PROGRESS_KEY to stop
using old values.
SEO and sharing#
- Each page calls
usePageSeo({ title, description, page })(app/composables/usePageSeo.ts): the title and description, a canonical URL, Open Graph tags (withog-image.png, 1200×630) and asummary_large_imageTwitter card. The sermon page passes getters, so its title and description follow the sermon shown. The 404 page passesnoindex: true. - Set
SITE_URLwhen building so canonical and image URLs are absolute and point at your domain (otherwise they usehttps://riverbend.example):
SITE_URL=https://www.yourchurch.org pnpm generate- For search, consider
Churchstructured data (aschema.orgtype) with your address and service times, added withuseHead({ script: [{ type: "application/ld+json", innerHTML: … }] })on the home page.
Accessibility#
- Skip link, landmarks, one
<h1>per page and a logical heading order. aria-current="page"on the current navigation item; the mobile menu usesaria-expanded, closes withEscand when the window grows to desktop width.- Visible focus outlines on every control; radio and checkbox cards show focus on the card.
- The audio player is fully keyboard operable, with labeled buttons and a native range slider with spoken time.
- The calendar is a table with a caption and column headers; day buttons have full-date labels with the number of
events; the event detail is a modal
<dialog>. - Filter results and counts are announced with
role="status"; form errors are linked to their fields and the thank-you message is focused. - Illustrations have
<title>text (the map also has a<desc>); decorative SVG is hidden from screen readers. - Color contrast meets WCAG AA;
prefers-reduced-motionturns off reveals, the live pulse and the river shimmer.
When editing, keep one <h1> per page, give every new input a <label>, and don't use apricot for text.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route in routes in nuxt.config.ts as <page>.html (sermons.html, linked as
/sermons), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which
Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages,
GitHub Pages, S3, Vercel). Hosts that don't map /sermons to sermons.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/church pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
- The countdown is an hour off. Check
CHURCH.timeZoneis your church's IANA time zone (for exampleAmerica/New_York), not a city name or an offset. - My sermons or events have the wrong dates.
KEEP_DEMO_CURRENTis stilltrue, so dates are moved forward. Set it tofalseinapp/data/church.ts. - Dates and lists appear a moment after the page. That's by design: they depend on the visitor's clock or the address and are rendered in the browser (see Demo dates).
- The player says playback is simulated. The sermon's
audiois empty. Add a URL to an audio file; if it is on another domain, that server must allow it to be played (most podcast hosts do). - A new sermon, event or group doesn't type-check. Its
series,speakerorcatmust be one of the ids inSeriesId,PersonIdorEventCategoryId; add new ids to those types. - "Copy questions" fails. The clipboard needs
httpsorlocalhost. - A class has no effect. Tailwind only sees class names written out in full; build them with complete names
(
"bg-plum"), not by joining fragments. - Images or links break in a sub-folder. Build with
DEMO_BASE_PATH, useNuxtLinkfor pages and$config.app.baseURLfor files inpublic/. - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.
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. Nunito Sans and
Urbanist are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Names, places and people are
fictional.