Freeframe

Documentation

Fitness

v1.0.1Updated Nov 11, 2026A bold, dark gym and fitness studio site with a bookable class schedule.

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).

sh
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.dev

Customizing#

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:

css
@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:

css
@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:

ts
{ 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) in app/pages/index.vue;
  • the plan options and prices in the trial form (PLAN_OPTIONS in TrialForm.vue, keep them in sync with PLANS in site.ts), its home club options, and the "what's included" list (PERKS) in app/pages/trial.vue;
  • the email addresses and map drawings (MAPS, one per club id) in app/pages/contact.vue, and the topics (TOPICS) and club options in ContactForm.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 in SiteHeader.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.

  • Header links are NAV in app/data/nav.ts. SiteHeader.vue renders them in the desktop menu and the numbered mobile menu and marks the current section with aria-current="page" through useCurrentPage() (a class page counts as Classes and a coach page as Trainers, through NAV_PARENT).
  • The footer link columns are FOOTER_TRAIN and FOOTER_CLUB in the same file; SiteFooter.vue renders them with the club status and the newsletter form.
  • To add a page, create app/pages/<name>.vue with a <main id="main"> root and a usePageSeo({ title, description }) call, add its route to routes in nuxt.config.ts (so nuxt generate prerenders it), and add a NAV or 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:

  • OpenStatus shows "Open now · until 10pm", "Closed · opens 5:30am" or "Closed · opens 6am tomorrow" (from openStatus()), or its fallback text. With badge it 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>.

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 Email
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:

ts
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", image img/og-image.png) and a large Twitter card. The 404 page passes noindex: true.
  • Canonical and social URLs are absolute: SITE_URL plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-gym.example pnpm generate
  • /class and /trainer are 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 in routes in nuxt.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 h1 per page;
  • visible focus styles (:focus-visible in main.css);
  • the ARIA tabs pattern for day tabs, a native <dialog> with focus return, aria-pressed filter buttons, role="switch" with aria-checked for billing, aria-current for 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 /classes from classes.html (most do) and 404.html for 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_PATH set, and reference public files with useAsset() (NuxtLink adds the base path to links on its own).
  • Social previews use keel.example. Set SITE_URL when building.
  • Open/closed status or "next up" looks wrong. Times are club time, not the visitor's: set TIMEZONE in app/data/forge.ts to 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 of CLASSES/COACHES.
  • A new page is missing from the build. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) first, which runs nuxt 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.

Search documentation

Search the guides and every template's documentation