Freeframe

Documentation

Fitness

v1.0.2Updated Oct 16, 2026A bold, dark gym and fitness studio site with a bookable class timetable.

Overview#

Fitness is a bold, dark website for a gym or fitness studio, shown as Forge Athletic Club, a fictional club with two locations (Harbor and Northgate), six class types, six coaches and 36 weekly sessions. It has a filterable weekly timetable 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 Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.

Live demo: freeframe.dev/templates/fitness

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static site in out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck Generate route types (next typegen), then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/fitness into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx 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 src/app/classes/page.tsx Weekly timetable: filters by class, intensity and coach, day tabs on phones, spots-left badges, booking dialog
/class?type=<id> src/app/class/page.tsx One class: summary, duration, calories, intensity, what to expect, equipment, coaches, upcoming sessions
/trainers src/app/trainers/page.tsx All coaches with a specialty filter, personal training call to action
/trainer?id=<id> src/app/trainer/page.tsx One coach: portrait, quote, bio, years, classes a week, certifications, classes coached
/membership src/app/membership/page.tsx Three plans with a monthly/yearly switch, comparison table, add-ons, BMI and calorie calculator, FAQ
/trial src/app/trial/page.tsx Three-step free-trial form: goal, details, review
/contact src/app/contact/page.tsx Two clubs with stylised maps, open-now status and opening hours, contact form
404 src/app/not-found.tsx "Looks like a rest day" with links home and to the timetable

Classes and coaches each use one route with a query string (/class?type=box, /trainer?id=lena) rather than a dynamic segment, so the whole site can be a static export. The footer (with the newsletter form) is on every page.

Project structure#

src/
├── app/
│   ├── layout.tsx            fonts, site metadata, header, footer, toast, reveal observer, back-to-top
│   ├── page.tsx              home
│   ├── <route>/page.tsx      one folder per page (classes, class, trainers, trainer, membership, trial, contact)
│   ├── not-found.tsx         404 page
│   ├── globals.css           Tailwind import, design tokens (@theme) and component classes
│   └── icon.svg              favicon
├── components/
│   ├── Timetable.tsx         timetable, filters, day tabs and booking dialog
│   ├── ClassView.tsx         class page (ClassFromUrl reads ?type=)
│   ├── TrainerView.tsx       coach page (TrainerFromUrl reads ?id=)
│   ├── CoachGrid.tsx         coach cards with the specialty filter
│   ├── Cards.tsx             ClassCard, CoachCard, PageHero, stagger()
│   ├── NextUp.tsx            home page "next up today"
│   ├── OpenStatus.tsx        OpenStatus (open now / closed) and HoursList
│   ├── SpotsBadge.tsx        "Booked", "Full · waitlist", "2 left", "9 spots"
│   ├── Billing.tsx           monthly/yearly switch, Price, BillingNote
│   ├── Calculator.tsx        BMI and calorie calculator
│   ├── TrialForm.tsx         three-step free-trial form
│   ├── ContactForm.tsx       contact form (ContactFromUrl reads ?topic=)
│   ├── NewsletterForm.tsx    footer sign-up
│   ├── FieldError.tsx        inline error message for a field
│   ├── SiteHeader.tsx        header and mobile menu
│   ├── SiteFooter.tsx        footer links, club status, newsletter
│   └── CountUp, Toast, RevealObserver, BackToTop, Icons, Year
├── data/
│   ├── forge.ts              TIMEZONE, CLASSES, COACHES, LOCATIONS, SESSIONS
│   ├── site.ts               PLANS, COMPARE, ADDONS, MEMBERSHIP_FAQ, STORIES, FACILITIES, CLUBS
│   └── nav.ts                header and footer links
├── fonts/                    Anton and Inter (woff2) with their OFL licences
└── lib/
    ├── club.ts               club time, opening status, isClass()/isCoach(), DAY_NAMES
    ├── client.ts             useClubNow(), bookings in localStorage, toast()
    ├── validation.ts         useValidation(), fieldMessage(), fieldsIn()
    ├── title.ts              useDocumentTitle()
    ├── asset.ts              asset(): prefixes public files with the base path
    └── metadata.ts           pageMetadata(): title, description and Open Graph per page
public/img/                   SVG artwork, og-image.svg and og-image.png
next.config.ts                static export and optional base path
template.json                 template metadata used by freeframe.dev

Customising#

Brand and colours#

Design tokens live in the @theme block at the top of src/app/globals.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 in pnpm dev and pnpm build, 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: globals.css (focus glow, selected option cards, .glow), the SVG figures in TrialForm.tsx, not-found.tsx and the map pins in src/app/contact/page.tsx, the gauge in Calculator.tsx, accent-[#c8ff2e] on the trial terms checkbox, and the artwork in public/img/ and src/app/icon.svg. The browser theme colour is viewport.themeColor in src/app/layout.tsx.

The design is dark by design: viewport.colorScheme 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 with next/font/local in src/app/layout.tsx, from the same Latin files Google Fonts serves, so the visitor's browser never contacts Google:

tsx
const anton = localFont({ src: "../fonts/Anton-latin.woff2", variable: "--font-anton", weight: "400", display: "swap" });
const inter = localFont({ src: "../fonts/Inter-latin.woff2", variable: "--font-inter", weight: "400 800", display: "swap" });

The files and their SIL Open Font License texts (Anton-OFL.txt, Inter-OFL.txt) are in src/fonts/. To swap a font, add its .woff2 to src/fonts/ and point src at it (or import a family from next/font/google), keep the same variable name, and update the fallback names in --font-display / --font-sans in globals.css.

Content#

Classes, coaches, clubs and the timetable are typed data in src/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 timetable
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, timetable, /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, timetable
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 timetable: id, day (0 = Monday … 6 = Sunday), time, type, coach, loc, cap, booked Timetable, "next up", class pages

src/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 and class page 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 CoachGrid.tsx in tags (or add a filter).

Some content is written directly in components and pages and must be edited there:

  • the hero stats ("2,400+ members", "36 classes / week", "4.9") and results figures in src/app/page.tsx, and the three home-page coaches (FEATURED_COACHES);
  • the plan options and prices in the trial form (PLANS in TrialForm.tsx, keep them in sync with PLANS in site.ts), its home club options, and the "what's included" list (INCLUDED) in src/app/trial/page.tsx;
  • the email addresses (EMAILS) and map drawings (MAPS, one per club id) in src/app/contact/page.tsx, and the topics (TOPICS) and club options in ContactForm.tsx;
  • the footer's fallback hours, social links and legal links in SiteFooter.tsx, and the "Open from 05:30" line in the mobile menu in SiteHeader.tsx;
  • 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 pageMetadata()
favicon.svg 32 × 32 Copy of src/app/icon.svg

src/app/icon.svg is the favicon (Next.js file convention). The contact page maps, the trial goal figures and the 404 figure are inline SVG in their components. After editing og-image.svg, export it again as a 1200 × 630 og-image.png.

Always reference files in public/ through asset(), so they keep working when the site is served from a sub-folder:

tsx
<img src={asset(`/img/class-${type}.svg`)} alt="" width={640} height={480} className="w-full rounded-2xl border border-line" />

To use photos, add .jpg/.webp files at the same aspect ratio and change the paths in Cards.tsx, ClassView.tsx, TrainerView.tsx and src/app/page.tsx. The site uses plain <img> on purpose: with a static export, next/image can't optimise images, so the @next/next/no-img-element lint rule is turned off in eslint.config.mjs.

  • Header links are NAV in src/data/nav.ts. SiteHeader.tsx renders them in the desktop menu and the numbered mobile menu and marks the current section with aria-current="page" (a class page counts as Classes, a coach page as Trainers, through its SECTIONS map).
  • The footer link columns are FOOTER_TRAIN and FOOTER_CLUB in the same file; SiteFooter.tsx renders them with the club status and the newsletter form.
  • To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata(title, description), and add a NAV or footer entry if it belongs in a menu.

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 src/lib/club.ts reads the date, weekday and minutes in TIMEZONE with Intl.DateTimeFormat. Set TIMEZONE in src/data/forge.ts to your club's IANA zone (for example "Europe/London").

The static HTML can't know the time, so useClubNow() in src/lib/client.ts returns null on the server and during hydration, then the club time in the browser, updated every minute. Until then components show a neutral fallback:

  • OpenStatus shows "Open now · until 22:00", "Closed · opens 05:30" or "Closed · opens 06:00 tomorrow" (from openStatus()), or its fallback text before hydration. With badge it renders the pill on the contact page, highlighted while open.
  • HoursList lists a club's hours, Monday first, and highlights today's row with aria-current="date".

Timetable#

Timetable.tsx 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" once the browser knows the club time.

Links can preselect filters and the day: /classes?type=box, /classes?coach=lena, /classes?day=4 (0 = Monday). TimetableFromUrl reads them with useSearchParams() inside <Suspense> in src/app/classes/page.tsx; the fallback is the unfiltered timetable, so the static HTML is complete. Ids match in any letter case, and only the data's own keys count (isClass()/isCoach() in src/lib/club.ts), so unknown values are ignored. With JavaScript off, a plain list of the week is shown in a <noscript> block.

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 timetable 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 Timetable.tsx with a call to its API, or link each session to its booking page.

Next up today#

NextUp.tsx 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 timetable (/classes?day=<n>). Before hydration it shows a link to the timetable instead.

Class and coach pages#

/class and /trainer are one static page each. src/app/class/page.tsx renders ClassFromUrl inside <Suspense> with <ClassView type={DEFAULT_CLASS} /> as the fallback, so the exported HTML contains the full default class (Strength Lab); in the browser ClassFromUrl reads ?type= with useSearchParams() and shows that class. /trainer?id= works the same way with TrainerFromUrl, TrainerView and DEFAULT_COACH (Maya Torres).

Ids are case-insensitive (?type=BOX works like ?type=box) and must be the data's own keys; anything else shows the default. useDocumentTitle() in src/lib/title.ts sets the page title to the chosen class or coach (and re-applies it if Next.js rewrites the head); the class page also updates the meta description.

The class page lists the coaches who teach it (from each coach's classes), and once the club time is known, this week's sessions in order from today, each linking to /classes?type=<id>&day=<n>. The coach page counts the coach's weekly sessions and links to /classes?coach=<id>.

Coach filter#

CoachGrid.tsx on /trainers has filter buttons (aria-pressed, from its FILTERS list) that show the coaches whose tags include that specialty (case-insensitive); All shows everyone. A polite live region announces the count.

Membership pricing switch#

BillingToggle in Billing.tsx is a role="switch" button with aria-checked. It shares the choice through BillingProvider with every Price (monthly or annual from PLANS) and BillingNote ("Billed monthly · cancel anytime" or the plan's annualNote). The choice isn't saved.

BMI and calorie calculator#

Calculator.tsx (on /membership#calculator) takes units (metric or imperial), 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 src/lib/client.ts works from any client component: it dispatches a toast event that the <Toast /> in the layout shows for about three seconds in a polite live region.

Header, mobile menu, reveals and counters#

SiteHeader.tsx turns the header solid 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). RevealObserver.tsx 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 the layout), content is visible straight away. CountUp.tsx 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.tsx, 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 globals.css.

Forms#

Form Where Validates
Free trial (data-trial) TrialForm.tsx 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.tsx Name, email, message of at least 10 characters
Newsletter NewsletterForm.tsx Email
Calculator (data-calc) Calculator.tsx Height, weight and age within range (nothing to send)

Validation comes from useValidation() in src/lib/validation.ts: spread field(id) on an input and render <FieldError id={id} message={errors[id]} /> after it. It uses the fields' own HTML constraints (required, type="email", pattern, min, max, minLength), shows messages from fieldMessage() on blur 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; both are read with useSearchParams() inside <Suspense>.

Nothing is sent until you connect the forms; the success messages say so. Each submit handler marks the place:

tsx
// To send for real: await fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form) })

To post to Formspree (or a similar service), make the handler async and replace that comment. In ContactForm.tsx:

tsx
onSubmit={async (e) => {
  e.preventDefault();
  const form = e.currentTarget;
  if (!v.validate(fieldsIn(form))) return;
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return; // show an error message here
  form.reset();
  flushSync(() => setDone(true));
  success.current?.focus();
}}

Do the same in NewsletterForm.tsx (or send to your email provider) and in onSubmit() in TrialForm.tsx, before setDoneName(...) (use new FormData(e.currentTarget); every field has a name). Update the success messages, which currently say the form is a demo.

If you remove the static export (see Building and deploying), you can post to your own Route Handler instead (for example src/app/api/contact/route.ts exporting POST), or turn a form into a Server Action. 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 src/lib/client.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 timetable. setBookings() saves a list and notifies every component; useBookings() keeps the timetable, badges, "next up" and class pages in sync (and across tabs through the storage event). On the server it returns an empty list, so bookings appear after hydration. spotsLeft() counts the visitor's own booking against cap.

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#

  • Site-wide defaults are metadata in src/app/layout.tsx. Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which sets the title as is, the description, Open Graph tags (site name "Forge Athletic Club", image asset("/img/og-image.png")) and a large Twitter card.
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to https://example.com). Set it when you build so social images get absolute URLs:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-gym.com pnpm build
  • /class and /trainer are single static 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 (/classes/[id], /trainers/[id]) with generateStaticParams().

For launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and ExerciseGym structured data with each club's address and opening hours as JSON-LD.

Accessibility#

Built in:

  • a "Skip to content" link (in layout.tsx), labelled landmarks and one h1 per page;
  • visible focus styles (:focus-visible in globals.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 labelled meter ("Intensity: High") and in words, not by colour alone;
  • form errors linked with aria-describedby/aria-invalid, focus moved to the first error and to each trial step;
  • live regions for timetable 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 build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • Any static host: upload the contents of out/.

To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/gym pnpm build. next.config.ts passes it to basePath and to NEXT_PUBLIC_BASE_PATH, which asset() uses. pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.

Need server features (a Route Handler for forms or bookings, Server Actions, middleware)? Remove output: "export" and the images.unoptimized setting from next.config.ts and deploy to a Node-capable host such as Vercel. Everything else keeps working.

TypeScript setup#

package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6 compiler API. Once typescript-eslint supports TypeScript 7, replace both with a single typescript@7. eslint.config.mjs also pins settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.

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}`, and in className template literals keep a space before each ${...} (as in `badge border border-line ${tone}`) so class names don't run together.
  • Images break when deployed to a sub-folder. Build with DEMO_BASE_PATH set, and reference public files with asset() (links made with next/link get the base path automatically).
  • Social previews use example.com. Set NEXT_PUBLIC_SITE_URL when building.
  • Open/closed status or "next up" looks wrong. Times are club time, not the visitor's: set TIMEZONE in src/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.
  • useSearchParams() should be wrapped in a suspense boundary during build. Any component that reads the query string must render inside <Suspense>, as on /classes, /class, /trainer, /trial and /contact.
  • 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#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. Anton and Inter are licensed under the SIL Open Font License (src/fonts/). Forge Athletic Club, its coaches and its members are fictional.

Search documentation

Search the guides and every template's documentation