Overview#
Fitness is a bold, dark website for a gym or fitness studio, shown as Keel Athletic Club, a fictional club with two locations in New Bedford, Massachusetts (Harbor and Northgate), six class types, six coaches and 36 weekly sessions. It has a filterable weekly schedule with a booking dialog and bookings saved in the browser, class and coach pages built from the data, membership plans with a monthly/yearly switch, a BMI and calorie calculator, a three-step free-trial form and live open-now hours in the clubs' time zone. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.
Live demo: freeframe.dev/templates/fitness
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ (every page prerendered to <page>.html) |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm typecheck |
nuxt prepare, then vue-tsc |
pnpm lint |
ESLint (flat config from @nuxt/eslint) |
pnpm build:demo |
Build for /demos/nuxt/fitness into demo/ (the freeframe.dev demo; you can delete it) |
pnpm install also runs nuxt prepare, which writes the generated types and ESLint config to .nuxt/.
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with count-up stats, class-name ticker, "next up today", six class cards, results, membership teaser, three coaches, member stories, facilities, free-week banner |
/classes |
app/pages/classes.vue |
Weekly schedule: filters by class, intensity and coach, day tabs on phones, spots-left badges, booking dialog |
/class?type=<id> |
app/pages/class.vue |
One class: summary, duration, calories, intensity, what to expect, equipment, coaches, upcoming sessions, other classes |
/trainers |
app/pages/trainers.vue |
All coaches with a specialty filter, personal training call to action |
/trainer?id=<id> |
app/pages/trainer.vue |
One coach: portrait, quote, bio, years, classes a week, certifications, classes coached |
/membership |
app/pages/membership.vue |
Three plans with a monthly/yearly switch, comparison table, add-ons, BMI and calorie calculator, FAQ |
/trial |
app/pages/trial.vue |
Three-step free-trial form: goal, details, review |
/contact |
app/pages/contact.vue |
Two clubs with stylized maps, open-now status and opening hours, contact form |
| 404 | app/error.vue |
"Looks like a rest day" with links home and to the schedule (generated as 404.html) |
Classes and coaches each use one page with a query string (/class?type=box, /trainer?id=lena) rather than a
dynamic route, so the whole site is a handful of static files. The header and the footer (with the newsletter form)
are on every page.
Project structure#
app/
├── app.vue layout and page; pages are keyed by path and query (see "Links to the same page")
├── layouts/default.vue skip link, header, footer, toast and back-to-top button
├── error.vue 404 page
├── pages/ one file per page (index, classes, class, trainers, trainer, membership, trial, contact)
├── components/
│ ├── ScheduleBoard.vue schedule, filters, day tabs and the booking dialog
│ ├── NextUp.vue home page "next up today"
│ ├── ClassCard.vue class card (home page)
│ ├── CoachCard.vue coach card (home page and coaches page)
│ ├── PageHero.vue header band of the inner pages
│ ├── OpenStatus.vue open now / closed, as text or as a badge
│ ├── SpotsBadge.vue "Booked", "Full · waitlist", "2 left", "9 spots"
│ ├── IntensityMeter.vue three-bar intensity meter
│ ├── BmiCalculator.vue BMI and calorie calculator
│ ├── TrialForm.vue three-step free-trial form
│ ├── ContactForm.vue contact form (reads ?topic=)
│ ├── NewsletterForm.vue footer sign-up
│ ├── FieldError.vue inline error message for a field
│ ├── SiteHeader.vue header and mobile menu
│ ├── SiteFooter.vue footer links, club status, newsletter, year
│ └── CountUp, BackToTop, KeelMark, ArrowIcon, CheckIcon, FacilityIcon, GoalFigure
├── composables/
│ ├── useClubNow.ts the time at the clubs, once the page has loaded
│ ├── useBookings.ts the visitor's bookings, shared on the page
│ ├── useValidation.ts useValidation(), fieldMessage(), fieldsIn()
│ ├── useCurrentPage.ts aria-current for menu links
│ ├── usePageSeo.ts title, description, canonical and Open Graph tags per page
│ └── useAsset.ts prefixes files in public/ with the base path
├── data/
│ ├── forge.ts TIMEZONE, CLASSES, COACHES, LOCATIONS, SESSIONS
│ ├── site.ts PLANS, COMPARE, ADDONS, MEMBERSHIP_FAQ, STORIES, FACILITIES, CLUBS
│ └── nav.ts header and footer links
├── utils/
│ ├── club.ts club time, opening status, isClass()/isCoach(), clock(), DAY_NAMES
│ ├── bookings.ts getBookings(), spotsLeft(), BOOK_KEY
│ ├── safeStorage.ts localStorage with an in-memory fallback
│ ├── toast.ts toast()
│ └── reference.ts makeRef()
├── plugins/
│ ├── reveal.client.ts scroll reveals
│ ├── same-page.client.ts links to the page already showing
│ └── stable-build.server.ts reproducible builds
├── router.options.ts query strings written like the HTML version
└── assets/
├── css/main.css fonts, Tailwind import, design tokens (@theme) and component classes
└── fonts/ Anton and Inter (woff2) with their OFL licenses
public/img/ SVG artwork, favicon, og-image.svg and og-image.png
nuxt.config.ts base path, head defaults, SITE_URL and the prerendered pages
template.json template metadata used by freeframe.devCustomizing#
Brand and colors#
Design tokens live in the @theme block of app/assets/css/main.css:
| Token | Used for |
|---|---|
--color-bg, --color-surface, --color-raised |
Page, card and raised panel backgrounds |
--color-line |
Borders |
--color-ink, --color-muted |
Text and muted text |
--color-volt |
Main accent (#c8ff2e): buttons, highlights, focus rings, "booked" |
--color-volt-dim |
Accent hover |
--color-heat |
High intensity and "few spots left" |
--color-cool |
Low intensity |
For example, an orange accent:
@theme {
--color-volt: #ff8a1f;
--color-volt-dim: #e5730a;
}The accent sits behind dark text (buttons, the toast, selected chips), so keep it bright. Tailwind compiles through
@tailwindcss/vite in pnpm dev and pnpm generate, so there's no separate CSS step.
The accent also appears as a literal value in a few places; search for c8ff2e and 200 255 46 when you change it:
main.css (focus glow, selected option cards, .glow), KeelMark.vue, GoalFigure.vue, error.vue, the map pins
in app/pages/contact.vue, the gauge in BmiCalculator.vue, accent-[#c8ff2e] on the trial terms checkbox, and the
artwork in public/img/. The browser theme color is the theme-color meta tag in nuxt.config.ts.
The design is dark by design: color-scheme is dark and there is no light theme or theme toggle.
Typography#
Headlines use Anton (--font-display, a tall condensed face, via the .display class) and text uses Inter
(--font-sans). Both are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css, using
the same Latin files Google Fonts serves, so the visitor's browser never contacts Google:
@font-face {
font-family: "Anton";
font-weight: 400;
font-display: swap;
src: url("../fonts/Anton-latin.woff2") format("woff2");
}To swap a font, add its .woff2 to app/assets/fonts/, change the @font-face rule, and update the family names in
--font-display / --font-sans.
Content#
Classes, coaches, clubs and the schedule are typed data in app/data/forge.ts:
| Export | What it holds | Used by |
|---|---|---|
TIMEZONE |
The clubs' IANA time zone ("America/New_York") |
Open-now status, today's hours, "next up", today in the schedule |
CLASSES |
Class types keyed by id (strength, ignite, ride, box, flow, engine): name, short, desc, intensity (1–3), duration (minutes), kcal, expect, equipment, who, tag |
Home cards, ticker, schedule, /class |
DEFAULT_CLASS |
Class shown on /class without a valid ?type= ("strength") |
/class |
COACHES |
Coaches keyed by id (maya, dev, jonah, lena, sami, chloe): name, role, tags, classes, years, certs, quote, bio, handle |
Coach cards, filter, /trainer, schedule |
DEFAULT_COACH |
Coach shown on /trainer without a valid ?id= ("maya") |
/trainer |
LOCATIONS |
Clubs keyed by id (harbor, northgate): name and hours, one [open, close] pair per weekday, Monday first |
Open status, hours lists |
SESSIONS |
The weekly schedule: id, day (0 = Monday … 6 = Sunday), time (24-hour; clock() in app/utils/club.ts shows it as 12-hour), type, coach, loc, cap, booked |
Schedule, "next up", class pages |
app/data/site.ts holds the rest: PLANS (id, name, blurb, monthly and annual price per month, annualNote,
features, popular), COMPARE (the comparison table rows; true/false render as included/not included),
ADDONS, MEMBERSHIP_FAQ, STORIES (member stories on the home page), FACILITIES and CLUBS (address and phone
per location).
To add a session, add an object to SESSIONS. TypeScript checks that type, coach and loc are existing ids:
{ id: "s36", day: 6, time: "12:00", type: "box", coach: "jonah", loc: "northgate", cap: 16, booked: 3 },Keep id unique and stable: it's what visitors' saved bookings refer to.
To add a class, add an entry to CLASSES (its key is the id used in /class?type=), add
public/img/class-<id>.svg, add its id to the classes of the coaches who teach it and add sessions. The home cards,
ticker, filters, class page and "Explore other classes" pick it up automatically. To add a coach, add an entry to
COACHES and public/img/coach-<id>.svg; use specialties from the FILTERS list in app/pages/trainers.vue in
tags (or add a filter).
Some content is written directly in pages and components and must be edited there:
- the hero stats ("2,400+ members", "36 classes / week", "4.9"), the results figures and the three home-page coaches
(
featuredCoaches) inapp/pages/index.vue; - the plan options and prices in the trial form (
PLAN_OPTIONSinTrialForm.vue, keep them in sync withPLANSinsite.ts), its home club options, and the "what's included" list (PERKS) inapp/pages/trial.vue; - the email addresses and map drawings (
MAPS, one per club id) inapp/pages/contact.vue, and the topics (TOPICS) and club options inContactForm.vue; - the footer's fallback hours, social links and legal links in
SiteFooter.vue, and the "Open from 5:30am" line in the mobile menu inSiteHeader.vue; - the personal training price on
/trainers.
To add a location, add it to LOCATIONS (hours) and CLUBS (address), add a MAPS entry on the contact page, and
update the footer, trial and contact form options.
Images and artwork#
Artwork is SVG in public/img/:
| Files | Size (aspect ratio) | Where |
|---|---|---|
hero.svg |
640 × 680 | Home hero |
class-<id>.svg |
640 × 480 (4:3) | Class cards and class page |
coach-<id>.svg |
400 × 480 (5:6) | Coach cards and coach page |
og-image.svg |
1200 × 630 | Source of the social image |
og-image.png |
1200 × 630 | Social image used by usePageSeo() |
favicon.svg |
32 × 32 | Favicon (set in nuxt.config.ts) |
The contact page maps, the trial goal figures (GoalFigure.vue) and the 404 figure are inline SVG. After editing
og-image.svg, export it again as a 1200 × 630 og-image.png.
Reference files in public/ through useAsset(), so they keep working when the site is served from a sub-folder:
<script setup lang="ts">
const asset = useAsset();
</script>
<template>
<img :src="asset(`img/class-${type}.svg`)" alt="" width="640" height="480" class="w-full rounded-2xl border border-line">
</template>To use photos, add .jpg/.webp files at the same aspect ratio and change the paths in ClassCard.vue,
CoachCard.vue, app/pages/class.vue, app/pages/trainer.vue and app/pages/index.vue.
Navigation, header and footer#
- Header links are
NAVinapp/data/nav.ts.SiteHeader.vuerenders them in the desktop menu and the numbered mobile menu and marks the current section witharia-current="page"throughuseCurrentPage()(a class page counts as Classes and a coach page as Trainers, throughNAV_PARENT). - The footer link columns are
FOOTER_TRAINandFOOTER_CLUBin the same file;SiteFooter.vuerenders them with the club status and the newsletter form. - To add a page, create
app/pages/<name>.vuewith a<main id="main">root and ausePageSeo({ title, description })call, add its route toroutesinnuxt.config.ts(sonuxt generateprerenders it), and add aNAVor footer entry if it belongs in a menu.
Links use <NuxtLink to="/classes">, which adds the base path. NuxtLink marks any link to the current page with
aria-current="page" on its own, so links that must not be marked (the logo, the footer, the header's "Free trial")
pass :aria-current="undefined".
Features#
Club time and opening hours#
Everything that depends on "now" uses the time at the clubs, not on the visitor's device: businessNow() in
app/utils/club.ts reads the date, weekday and minutes in TIMEZONE with Intl.DateTimeFormat. Set TIMEZONE in
app/data/forge.ts to your club's IANA zone (for example "America/Chicago").
The generated HTML can't know the time, so useClubNow() returns null until the page has loaded in the browser,
then the club time. Until then components show the text written in the HTML version:
OpenStatusshows "Open now · until 10pm", "Closed · opens 5:30am" or "Closed · opens 6am tomorrow" (fromopenStatus()), or itsfallbacktext. Withbadgeit renders the pill on the contact page, highlighted while open.- The contact page highlights today's row in each club's hours with
aria-current="date".
Schedule#
ScheduleBoard.vue renders every session in SESSIONS on /classes:
- On large screens all seven days show side by side; on phones day tabs show one day at a time, starting on today. The tabs follow the ARIA tabs pattern: Left/Right arrows move between days, Home and End jump to the ends, and only the selected tab is in the tab order.
- Three selects filter by class, intensity and coach; Reset clears them, and a polite live region says how many classes match ("24 classes this week"). Days with no match say "No matching classes."
- Each session shows its time, an intensity meter, coach, club and a
SpotsBadge: "9 spots", "2 left" (3 or fewer), "Full · waitlist" or "Booked". - Today's column is marked "Today".
Links can preselect filters and the day: /classes?type=box, /classes?coach=lena, /classes?day=4 (0 = Monday).
readUrl() reads them once the page has loaded. Ids match in any letter case, and only the data's own keys count
(isClass()/isCoach() in app/utils/club.ts), so unknown values are ignored. The generated HTML (and a browser
with JavaScript off) shows a plain list of the week in a <noscript> block; the board replaces it in the browser.
Booking dialog#
Clicking a session opens a native <dialog id="booking-modal"> with the day and time, coach, club, duration, spots
left and the class summary, plus one action: Book my spot, Cancel booking, or Join waitlist when the class
is full. Booking adds the session id to the visitor's bookings (see Saved data) and shows
a toast; the session then shows "Booked" everywhere. The waitlist only shows a message. Esc, the close button or a
click on the backdrop closes the dialog, and focus returns to the session. Clear my bookings below the schedule
removes them all.
cap and booked in SESSIONS are demo numbers that never change for other visitors. For real bookings, connect a
class-booking or gym-management service: replace act() in ScheduleBoard.vue with a call to its API, or link each
session to its booking page.
Next up today#
NextUp.vue on the home page lists the next four sessions later today at the clubs (or tomorrow's first four once
today's are over), each linking to that day on the schedule (/classes?day=<n>). The generated HTML shows a link to
the schedule instead.
Class and coach pages#
/class and /trainer are one generated page each, showing the default class (Strength Lab) and coach (Maya
Torres). Once the page has loaded, app/pages/class.vue reads ?type= and app/pages/trainer.vue reads ?id= and
show that class or coach. Ids are case-insensitive (?type=BOX works like ?type=box) and must be the data's own
keys; anything else shows the default. The tab title follows the class or coach, and the class page also updates the
meta description.
The class page lists the coaches who teach it (from each coach's classes), this week's sessions in order from today
(each linking to /classes?type=<id>&day=<n>) and chips for the other classes, with the current one marked. The coach
page counts the coach's weekly sessions and links to /classes?coach=<id>.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, so app/app.vue keys each page
by its path and query: following a link to another class from a class page starts the page afresh, and
app/plugins/same-page.client.ts scrolls it to the top. A link to the page already showing (the footer's "Schedule"
on the schedule) restarts the page too, clearing filters and forms like a reload.
Coach filter#
The filter buttons on /trainers (aria-pressed, from FILTERS in app/pages/trainers.vue) show the coaches whose
tags include that specialty (case-insensitive); All shows everyone. A polite live region announces the count.
Membership pricing switch#
The switch on /membership is a role="switch" button with aria-checked. It changes each plan's price (monthly
or annual from PLANS) and note ("Billed monthly · cancel anytime" or the plan's annualNote). The choice isn't
saved.
BMI and calorie calculator#
BmiCalculator.vue (on /membership#calculator) takes units (imperial by default, or metric), height, weight, age,
sex and activity level and shows:
- BMI = weight (kg) ÷ height (m)², with a category (under 18.5, 18.5–25, 25–30, 30 and over) and a marker on a 15–40 scale;
- BMR with the Mifflin-St Jeor equation, and maintenance calories (BMR × 1.2, 1.375, 1.55 or 1.725);
- targets for fat loss (maintenance − 450 kcal) and muscle gain (+ 300 kcal), set in
onSubmit().
Results are in a polite live region. The figures are estimates, not medical advice; keep the disclaimer.
Toasts#
toast(message) from app/utils/toast.ts works from any component: the toast in app/layouts/default.vue shows the
message for about three seconds in a polite live region.
Header, mobile menu, reveals and counters#
SiteHeader.vue frosts the header after scrolling. The menu button toggles the full-screen mobile menu
(aria-expanded, Esc closes it and returns focus to the button, and it closes on navigation or on wide screens).
app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view, revealing everything above too;
with reduced motion, or before JavaScript runs (the js class is set by a small script in nuxt.config.ts), content
is visible straight away. Don't put .reveal on an element whose class changes with state: Vue would drop the
is-visible class the plugin adds. CountUp.vue counts numbers up once when visible; the final value is in the
HTML, and with reduced motion it isn't animated. The class-name ticker is hidden from screen readers, pauses on hover
and stops with reduced motion.
Back to top#
BackToTop.vue, mounted once in the layout, shows a button after about one screen of scrolling. It scrolls to the top
(instantly with reduced motion), moves focus to <main>, lifts above the toast and elements marked
data-back-to-top-avoid, and hides while it would cover a pinned element marked data-back-to-top-hide. To remove
it, delete <BackToTop /> from the layout and the .back-to-top block at the end of main.css.
Forms#
| Form | Where | Validates |
|---|---|---|
Free trial (data-trial) |
TrialForm.vue |
Step 1: a goal; step 2: first and last name, email, phone (at least 7 digits, spaces or + ( ) -), start date from today; step 3: terms checkbox |
| Contact | ContactForm.vue |
Name, email, message of at least 10 characters |
| Newsletter | NewsletterForm.vue |
|
Calculator (data-calc) |
BmiCalculator.vue |
Height, weight and age within range (nothing to send) |
Validation comes from useValidation() in app/composables/useValidation.ts. Put its listeners on the form, bind
v.attrs(id) on each field and render a FieldError after it:
<form novalidate @submit="onSubmit" @focusout="v.blur" @input="v.recheck" @change="v.recheck">
<input id="m-name" class="field" name="name" required v-bind="v.attrs('m-name')">
<FieldError id="m-name" :message="v.errors['m-name']" />
</form>It uses the fields' own HTML constraints (required, type="email", pattern, min, max, minlength), shows
messages from fieldMessage() when you leave a filled-in field and on submit, links them with
aria-describedby/aria-invalid, and focuses the first invalid field. The trial start date's min is today at the
clubs.
The trial form moves focus to each step's heading and marks the current step with aria-current="step". ?plan=
preselects a plan (/trial?plan=elite; the value must be offpeak, unlimited or elite), and the membership
page's plan buttons link there. ?topic= preselects the contact topic (/contact?topic=pt; membership, pt,
corporate or other). Both match in any letter case (?plan=Elite, ?topic=PT), and only the listed values count.
The contact confirmation says the message reached the team and promises a reply within one business day; the free
trial one says the week is booked and promises the first-visit details by email within one business day. Both show a
short reference made in the browser by makeRef() in app/utils/reference.ts (FA- and four characters, like
FA-7K2Q). The newsletter confirmation asks subscribers to look out for a confirmation email. If your form service
returns its own reference, show that instead, and change the reply times to match how you actually handle messages.
Nothing is sent until you connect the forms, although the confirmations read as if it had been. To post to
Formspree (or a similar service), make onSubmit() in ContactForm.vue async and send the
form before showing the confirmation:
async function onSubmit(e: Event) {
e.preventDefault();
if (!v.validate(fieldsIn(form.value))) return;
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
form.value?.reset();
reference.value = makeRef("FA");
nextTick(() => success.value?.focus());
}Do the same in NewsletterForm.vue (or send to your email provider) and in onSubmit() in TrialForm.vue, where a
comment marks the place (every field has a name). If you deploy the server build (pnpm build) instead of the
static site, you can also post to your own Nuxt server route (for example server/api/contact.post.ts); validate
again on the server either way.
Saved data (localStorage)#
| Key | Shape |
|---|---|
forge-bookings (BOOK_KEY) |
Array of session ids, for example ["s04", "s17"] |
The helpers are in app/utils/bookings.ts. getBookings() validates the value on every read: anything that isn't an
array of known, unique session ids is dropped and the stored copy is repaired, so corrupt or tampered data can't break
the schedule. useBookings() shares the list between the schedule, badges, "next up" and class pages, reads it once
the page has loaded (the generated HTML has no bookings) and saves changes. spotsLeft() counts the visitor's own
booking against cap. Storage goes through safeStorage (app/utils/safeStorage.ts): when the browser blocks
localStorage (a sandboxed frame, strict privacy settings) or it's full, bookings last for the current page view.
Bookings are per browser. Visitors clear them with Clear my bookings on /classes; developers can run
localStorage.removeItem("forge-bookings"). If you remove or rename a session id, old bookings for it are ignored and
cleaned up.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title as is, the description, a canonical link, Open Graph tags (site name "Keel Athletic Club", imageimg/og-image.png) and a large Twitter card. The 404 page passesnoindex: true. - Canonical and social URLs are absolute:
SITE_URLplus the base path. Set it when you build:
SITE_URL=https://www.your-gym.example pnpm generate/classand/trainerare single generated pages, so their HTML metadata is the default class and coach; the title for other ids is set in the browser. Search engines that don't run JavaScript only see the defaults. If class or coach pages matter for search, switch to dynamic routes (app/pages/classes/[id].vue) and list every id inroutesinnuxt.config.ts.
For launch, consider adding a sitemap.xml and robots.txt to public/, and
ExerciseGym structured data with each club's address and opening hours as JSON-LD
(useHead({ script: [...] })).
Accessibility#
Built in:
- a "Skip to content" link (in the layout), labeled landmarks and one
h1per page; - visible focus styles (
:focus-visibleinmain.css); - the ARIA tabs pattern for day tabs, a native
<dialog>with focus return,aria-pressedfilter buttons,role="switch"witharia-checkedfor billing,aria-currentfor navigation, steps and today's hours; - intensity shown as a labeled meter ("Intensity: High") and in words, not by color alone;
- form errors linked with
aria-describedby/aria-invalid, focus moved to the first error and to each trial step; - live regions for schedule and coach counts, calculator results, success messages and toasts;
- reduced motion respected by reveals, counters, the ticker, smooth scrolling and the back-to-top button;
- WCAG AA contrast on the dark background.
When editing, keep one h1 per page, label every input, keep alt text meaningful, and only put the accent behind
dark text.
Building and deploying#
pnpm generate writes a fully static site to .output/public/: one .html file per page (classes.html,
class.html...) with its _payload.json, 404.html, the _nuxt/ assets and public/. Deploy it anywhere:
- Netlify / Cloudflare Pages / Vercel: build command
pnpm generate, publish directory.output/public. - Any static host: upload the contents of
.output/public/. The host should serve/classesfromclasses.html(most do) and404.htmlfor unknown URLs.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/gym pnpm generate. nuxt.config.ts
passes it to app.baseURL, which NuxtLink, useAsset() and the assets use. pnpm build:demo does this for the
freeframe.dev demo and writes demo/, which you can delete.
Need server features (an API route for forms or bookings)? Run pnpm build and deploy .output/ to a Node.js host;
the pages stay prerendered.
Troubleshooting#
- A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source.
Write
"text-heat", not`text-${color}`. - Images break when deployed to a sub-folder. Build with
DEMO_BASE_PATHset, and reference public files withuseAsset()(NuxtLink adds the base path to links on its own). - Social previews use
keel.example. SetSITE_URLwhen building. - Open/closed status or "next up" looks wrong. Times are club time, not the visitor's: set
TIMEZONEinapp/data/forge.tsto the club's IANA zone. A visitor in another zone sees the clubs' day and hours. - A class or coach page shows Strength Lab or Maya Torres instead of the one I linked. The
?type=/?id=value must be a key ofCLASSES/COACHES. - A new page is missing from the build. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) first, which runsnuxt prepare.- Bookings vanished or show old sessions. They're stored per browser; private windows and cleared site data reset
them. Use Clear my bookings or
localStorage.removeItem("forge-bookings")to start over. - Forms "send" but nothing arrives. Connect them as described in Forms.
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. Anton and Inter
are licensed under the SIL Open Font License (app/assets/fonts/). Keel Athletic Club, its coaches and its
members are fictional.