Freeframe

Documentation

Clinic

v1.0.1Updated Oct 14, 2026A calm, accessible website for a dental or medical practice, with online booking.

Overview#

Clinic is a calm, accessible website for a dental or medical practice, shown as Brightside Dental, a fictional family practice in Portwell. It has a home page with a live open-now badge and a "next free slot" card, services with typical costs, a detailed treatment guide (dental implants), a filterable team page, patient information with a searchable insurance list, a contact page and a five-step booking wizard that only offers real opening hours and clinician days. Text is larger than usual and contrast is high for easy reading. 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/clinic

All treatment descriptions, prices and aftercare notes are placeholder copy, not medical advice. Replace them with your practice's own information.

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/clinic into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Hero with the "next free slot" card, services, why Brightside, team preview, reviews with rating breakdown, insurers, open-now badge and hours, map, call to action
/services src/app/services/page.tsx Four categories of expandable treatments with typical costs, jump links, payment plan prompt
/service src/app/service/page.tsx Treatment guide for dental implants: key facts, four-step timeline, aftercare, FAQ, sticky cost breakdown, related treatments
/team src/app/team/page.tsx Eight team members with credentials and languages, specialty filter
/appointments src/app/appointments/page.tsx Five-step booking wizard with a live summary (?service=<id> preselects a treatment)
/patients src/app/patients/page.tsx First visit, insurance search, downloadable forms, payment options, FAQ, privacy
/contact src/app/contact/page.tsx Emergency notice, contact form, open-now badge and hours, contact cards, map, directions, accessibility
404 src/app/not-found.tsx Page not found, with links home, to booking and to contact

Every inner page starts with PageHero (breadcrumb, eyebrow, h1 and intro). The footer on every page has the newsletter form and the opening hours.

Project structure#

src/
├── app/
│   ├── layout.tsx            fonts, metadata, header, footer, toast, reveal observer, back-to-top
│   ├── page.tsx              home
│   ├── <route>/page.tsx      one folder per page
│   ├── not-found.tsx         404 page
│   ├── globals.css           Tailwind import, design tokens (@theme) and component classes
│   └── icon.svg              favicon
├── components/               BookingWizard, OpeningHours (OpenStatus, HoursList, NextFreeSlot), TeamGrid,
│                             PlanSearch, FormDownloads, ContactForm, NewsletterForm, FieldError, FaqList,
│                             PageHero, SiteHeader, SiteFooter, Toast, RevealObserver, BackToTop, Icon, Year
├── data/
│   ├── practice.ts           practice details, TIMEZONE, opening hours, booking rules, clinicians, bookable treatments
│   ├── services.ts           services page categories and treatments with costs
│   ├── implants.ts           treatment guide content (/service)
│   ├── team.ts               team members and specialty filters
│   ├── patients.ts           first visit steps, insurance plans, patient forms, FAQ
│   ├── home.ts               home page cards, reasons, ratings, reviews, insurers
│   └── nav.ts                header, mobile and footer links
├── fonts/                    Figtree and Fraunces (Latin .woff2) with their OFL licences
└── lib/
    ├── schedule.ts           opening hours text, open-now status, time zone, appointment slots
    ├── client.ts             useZoneNow() and toast() (browser-only helpers)
    ├── validation.ts         useValidation(): inline form validation
    ├── asset.ts              asset(): prefixes public files with the base path
    ├── metadata.ts           pageMetadata(): title, description and Open Graph per page
    └── style.ts              delay(): staggered scroll-reveal delays
public/img/                   SVG portraits and illustrations, favicon.svg, 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-brand-50 ... --color-brand-900 Teal brand scale: buttons, links, icons, focus rings, the footer
--color-sun, --color-coral Warm accents (newsletter button, underline in the hero)
--color-sky-soft, --color-cream Soft section backgrounds
--color-ink, --color-body, --color-muted Headings, body text and secondary text
--color-line Borders
--radius-blob Rounded "blob" corners

To switch to a blue brand, replace the scale (keep the light-to-dark order):

css
@theme {
  --color-brand-500: #2f6fd1;
  --color-brand-600: #2459ad;
  --color-brand-700: #1d4789;
  --color-brand-800: #193b70;
  --color-brand-900: #16315c;
}

Keep --color-brand-600 and darker dark enough for white text (WCAG AA). Tailwind compiles on the fly in pnpm dev and pnpm build, so there's no separate CSS step. A few places use hex values directly: the browser theme colour (viewport.themeColor in src/app/layout.tsx), the open-now pill and error colours in globals.css, the button shadow (rgb(27 98 91 / .8)), and the emergency notices (bg-[#fdf0ea], text-[#7c2d12]). Search for the old values when you rebrand.

Typography#

Body text uses Figtree (--font-sans) and headings use Fraunces (--font-display). Both are self-hosted with next/font/local in src/app/layout.tsx, from src/fonts/Figtree-latin.woff2 and src/fonts/Fraunces-latin.woff2: the same Latin files Google Fonts serves, so no request goes to Google and text sets exactly like the HTML version. Their SIL Open Font License texts are src/fonts/Figtree-OFL.txt and src/fonts/Fraunces-OFL.txt.

tsx
const figtree = localFont({ src: "../fonts/Figtree-latin.woff2", variable: "--font-figtree", weight: "400 800", display: "swap" });
const fraunces = localFont({ src: "../fonts/Fraunces-latin.woff2", variable: "--font-fraunces", weight: "500 700", display: "swap" });

To swap a font, add its .woff2 (and licence) to src/fonts/, point src at it and keep the variable name, or import a family from next/font/google with the same variable. Then update the fallback names in --font-sans and --font-display. The base size is font-size: 106.25% on html in globals.css; keep it large for readability.

Content#

Content that repeats or drives features lives in typed files in src/data/:

Export File What it holds
PRACTICE practice.ts name, phone, phoneHref, email, street, town, shortAddress
TIMEZONE practice.ts The practice's IANA time zone ("America/Los_Angeles")
HOURS, LUNCH practice.ts Opening hours per weekday and the weekday lunch break
SLOT_STEP, MIN_NOTICE_MINUTES, WEEKS_AHEAD practice.ts Booking rules
CLINICIANS practice.ts Bookable clinicians keyed by id: name, role, days
BOOKABLE practice.ts Bookable treatments: id, name, minutes, note, who
INSURANCE_OPTIONS practice.ts Insurance choices in the booking form
CATEGORIES services.ts Services page categories (id, title, icon, intro, treatments)
TEAM, SPECIALTIES team.ts Team members and the filter buttons
FACTS, STEPS, AFTERCARE, IMPLANT_FAQ, COSTS, TOTAL, RELATED implants.ts Treatment guide
FIRST_VISIT, PLANS, PATIENT_FORMS, PATIENT_FAQ patients.ts Patients page
HOME_SERVICES, REASONS, RATINGS, REVIEWS, INSURERS home.ts Home page
NAV, MOBILE_NAV, FOOTER_LINKS nav.ts Menus

Opening hours are 24-hour decimals in the practice's time zone (8.5 = 8:30 am; null = closed):

ts
export const HOURS: Record<number, DayHours | null> = {
  0: null,
  1: [8, 19],
  2: [8, 19],
  3: [8, 19],
  4: [8, 19],
  5: [8, 16],
  6: [9, 14],
};

To add a bookable treatment, add an entry to BOOKABLE; who lists the CLINICIANS ids who offer it:

ts
{ id: "veneers", name: "Veneers consultation", minutes: 45, note: "Shade and shape planning", who: ["dr-kim"] },

It appears in step 1 of the wizard and can be preselected with /appointments?service=veneers.

To add a clinician, add them to CLINICIANS (with the weekdays they work, 0 = Sunday) and to TEAM in team.ts (the team page), and add a portrait public/img/<id>.svg. CLINICIANS drives booking; TEAM drives the team page and the first four members appear on the home page. A TEAM member's specialty must be one of the SPECIALTIES ids.

Some content is written directly in the page and component files:

  • the hero statistics and the "Why Brightside" intro on the home page (src/app/page.tsx);
  • the payment options and privacy text on /patients (PAYMENT and the privacy section in src/app/patients/page.tsx);
  • the directions and accessibility notes on /contact, and the topics in ContactForm.tsx (TOPICS);
  • TRAINING on /team, and the monthly price and Dr. Alvarez card in the /service sidebar;
  • the "We're closed on Sundays" hint in step 3 of BookingWizard.tsx.

Images and artwork#

All artwork in public/img/ is original SVG:

Files Size Where
dr-okoye.svg, dr-hartmann.svg, dr-raman.svg, dr-alvarez.svg, dr-kim.svg, jordan-ellis.svg, sofia-marchetti.svg, grace-liu.svg 360 × 420 portrait Team cards, home team preview, booking wizard step 2
hero.svg 640 × 560 Home hero
map.svg 800 × 520 Home and contact pages
favicon.svg 32 × 32 Logo in the header and footer
og-image.svg, og-image.png 1200 × 630 Social image (SVG source, PNG export used by pageMetadata())

src/app/icon.svg is the browser favicon (Next.js file convention). Portraits are named after the person's id, and the team grid and wizard build the path from it (asset(`/img/${m.id}.svg`)). To use photos, keep the same proportions (portraits are cropped by object-cover), and change the extension where the path is built in TeamGrid.tsx, BookingWizard.tsx and the home page. Always reference files in public/ through asset() so they keep working under a base path:

tsx
<img src={asset("/img/hero.svg")} alt="Illustration of a bright, calm treatment room" width={640} height={560} />

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; the mobile menu uses MOBILE_NAV (Home plus NAV). SiteHeader.tsx renders both and marks the current section with aria-current="page" (the /service guide counts as Services). "Book appointment" and the phone number are buttons in both menus.
  • The emergency top bar above the header and the phone number come from PRACTICE.
  • The footer (SiteFooter.tsx) has the logo and newsletter form, the address, FOOTER_LINKS, and HoursList.
  • To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata(title, description), start it with PageHero, and add a NAV (and maybe FOOTER_LINKS) entry.

Features#

Time zone and hydration#

Everything time-dependent uses the practice's time, not the visitor's: set TIMEZONE in src/data/practice.ts (default "America/Los_Angeles"). nowInZone() in src/lib/schedule.ts returns a Date whose local fields (getDay(), getHours() ...) are the date and time in that zone, so they can be compared with HOURS directly.

Components read it through useZoneNow() in src/lib/client.ts. It returns null on the server and during hydration, then the current zone time, updated every minute. So the static HTML contains neutral placeholders and the open-now badge, today's hours row and bookable slots are filled in by the browser after hydration, with no hydration mismatches whatever the build machine's clock or zone.

Open-now badge and opening hours#

src/components/OpeningHours.tsx exports three components:

  • OpenStatus shows "Open now · closes at 7:00 pm" or "Closed now · opens tomorrow at 8:00 am" from openStatus(), in a .open-pill with data-open="true"/"false" for colour and role="status". Before hydration it reads "Checking today's hours...".
  • HoursList prints Monday to Sunday from HOURS with hoursText(), and marks today's row with data-today="true" and aria-current="date" once the zone time is known.
  • NextFreeSlot (home hero card) shows the first free 60-minute slot with anyone in the next 14 days from nextFreeSlot(), for example "Today, 2:30 pm".

The pulsing dot stops with reduced motion.

Booking wizard#

BookingWizard.tsx (on /appointments) is a single form with five fieldset[data-step] steps and a sticky summary (treatment, clinician, when, length):

  1. Treatment: from BOOKABLE, with each treatment's length.
  2. Clinician: the treatment's who list with portraits and working days, plus "First available".
  3. Date and time: a week of day buttons (up to WEEKS_AHEAD weeks, starting at the first week with free days) and the free start times for the chosen day.
  4. Your details: first, last, email, phone, dob, insurance (INSURANCE_OPTIONS), returning, notes (optional, 500 characters) and the required consent checkbox.
  5. Review: every answer with a "Change" button back to its step.

Slots come from slotsFor(date, minutes, clinicianIds, now) in src/lib/schedule.ts:

  • a start every SLOT_STEP minutes from opening, as long as the appointment ends by closing time;
  • the LUNCH window is skipped on weekdays (not Saturdays);
  • nothing sooner than MIN_NOTICE_MINUTES from now;
  • only clinicians whose days include that weekday;
  • some slots are marked as booked by isTaken(), a fixed demo pattern.

Past days, closed days and days with no free times are disabled, and booked times are struck through. With "First available", the summary names the first clinician free at the chosen time. Changing the treatment or clinician clears the chosen date and time.

Preselecting a treatment. /appointments?service=implant starts with that treatment chosen (the implant guide links to it). The value must match an id in BOOKABLE exactly, so only the data's own ids count (?service=constructor matches nothing). The query string is read in the browser, so no <Suspense> boundary is needed.

Nothing is booked. The confirmation says the practice would confirm by email and that nothing was sent. To take real bookings, replace isTaken() with your scheduling system's availability and send the booking where onSubmit() says:

tsx
// To take real bookings, send new FormData(e.currentTarget) plus the booking state to your scheduling system here.

The booking state b holds service, clinician, date, time and who. Many practices embed their booking provider's widget instead; you can render it in src/app/appointments/page.tsx in place of <BookingWizard />.

Accessibility: each step is a fieldset with a legend; focus moves to the legend on each step (scrolling with reduced motion respected); the progress list marks the current step with aria-current="step"; day buttons have labels such as "Thursday 8 Oct, 6 times available" or "..., closed"; selected days and times use aria-pressed; step errors use role="alert" and focus the first choice.

Team filter#

TeamGrid.tsx renders TEAM with one .chip button per entry in SPECIALTIES plus "Everyone". Buttons use aria-pressed, and a polite live region reads "Showing 2 of 8 team members: Hygiene". Cards have id anchors (/team#dr-okoye). To add a specialty, add it to SPECIALTIES and use its id as a member's specialty.

PlanSearch.tsx on /patients#insurance filters PLANS as you type (anywhere in the name, ignoring case), updates a polite count ("3 of 18 plans match") and shows a "call us" message when nothing matches. Add a plan to PLANS in src/data/patients.ts. The home page strip uses the shorter INSURERS list in home.ts.

Patient form downloads#

FormDownloads.tsx lists PATIENT_FORMS. Forms without an href are demo links that show a toast. Add your PDFs to public/ (for example public/forms/registration.pdf) and give each entry an href:

ts
{ title: "New patient registration", meta: "PDF · 2 pages", href: "/forms/registration.pdf" },

The href is used as written, so if you deploy under a base path, wrap it with asset() in FormDownloads.tsx.

Services and FAQs#

Treatments on /services and the FAQs on /service and /patients are native <details> disclosures (keyboard and screen-reader friendly, no script). FaqList.tsx takes an array of { q, a } (Faq). A treatment with a guide gets a link to its full guide.

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 and scroll reveals#

SiteHeader.tsx adds a shadow after scrolling. Below 1024px the menu button toggles #mobile-menu (aria-expanded, aria-controls); Esc closes it and returns focus to the button, and it closes on navigation or when the window gets wider. RevealObserver.tsx fades in .reveal elements as they scroll into view, revealing everything above too; delay() from src/lib/style.ts staggers them. With reduced motion, or before JavaScript runs (the no-js class is replaced with js by a small script in the layout), content is visible straight away.

Back to top#

BackToTop.tsx, mounted once in the layout, shows a button after about one screen of scrolling. It scrolls smoothly (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 (the sticky cost card on /service). Remove <BackToTop /> from the layout and the .back-to-top block in globals.css to drop it.

Forms#

Form Where Validates
Booking wizard BookingWizard.tsx (/appointments) A choice on steps 1 to 3; names, email, phone, date of birth and consent on step 4
Contact ContactForm.tsx (/contact) Name, email, message of at least 10 characters
Newsletter NewsletterForm.tsx (footer) Email

All three use useValidation() from src/lib/validation.ts. Spread v.field(id) on an input and render <FieldError id={id} message={v.errors[id]} /> after it; validateAll(scope) on submit shows every error and focuses the first invalid field. messageFor() checks required (including checkboxes), email format (EMAIL), phone numbers on fields with data-phone (at least 7 digits), minLength, and a date of birth (name="dob") not in the future. Errors appear on blur once a field has a value, update as you type, and are linked with aria-describedby and aria-invalid.

Nothing is sent until you connect the forms. The success messages say so. To post the contact or newsletter form to a form service such as Formspree, make the onSubmit handler async and replace the "To send for real" comment and the setSuccess(...) line:

tsx
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(form),
  headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn't send. Please call us instead.");
setSuccess("Thank you! We'll reply within one working day.");
form.reset();

(import toast from @/lib/client). For the booking wizard, send new FormData(form.current!) together with the booking state in onSubmit() before setDone(...).

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. Neither works with output: "export".

Health information is sensitive: send forms only over HTTPS to services you trust, ask for the minimum you need, and check your local privacy rules (for example HIPAA in the US or GDPR in the EU) before collecting medical details online.

SEO and sharing#

  • Site-wide defaults (title, description, Open Graph) are in metadata in src/app/layout.tsx.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts. The title is used as is, and the Open Graph image is asset("/img/og-image.png") with siteName from PRACTICE.name.
  • 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-practice.com pnpm build

For launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and Dentist or MedicalClinic structured data (address, phone, opening hours) as JSON-LD on the home page.

Accessibility#

Built in:

  • a "Skip to content" link (in layout.tsx), labelled landmarks and breadcrumbs, and one h1 per page;
  • larger base text, high contrast and thick 3px focus rings (:focus-visible in globals.css);
  • the wizard's fieldsets, legends, focus management and aria-current="step", with descriptive day labels;
  • aria-pressed on filter chips, days and times; aria-expanded on the menu button; aria-current for the current page and today's hours;
  • form errors linked with aria-describedby/aria-invalid, with focus moved to the first error;
  • live regions for the open-now badge, filter and plan counts, form success messages and toasts;
  • native <details> for FAQs and treatments;
  • reduced motion respected by reveals, smooth scrolling, the pulsing badge, card hover lifts and the back-to-top button;
  • print styles that hide the header and footer.

When editing, keep one h1 per page, label every new input, give images meaningful alt text (or alt="" when decorative), and keep text contrast at WCAG AA.

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=/clinic 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 (with NEXT_PUBLIC_SITE_URL=https://freeframe.dev) and writes demo/, which you can delete.

Need server features (a Route Handler or Server Actions for forms and bookings, 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#

  • The open-now badge or slots are an hour or more out. Set TIMEZONE in src/data/practice.ts to your practice's IANA zone name (for example "America/Chicago").
  • Opening hours changed, but some text still shows the old ones. Every hours table is printed from HOURS by hoursText(), so they update automatically, as do the badge and slots. Hand-written copy doesn't: check the "Hours that fit" reason in REASONS (src/data/home.ts), the step 3 hint in BookingWizard.tsx, and the NextFreeSlot placeholder ("Tomorrow, 9:30 am") shown before hydration.
  • A treatment has no free days. Check that its who clinicians work on open days (days in CLINICIANS) and that its minutes fit within the hours outside LUNCH.
  • ?service= doesn't preselect a treatment. The value must match an id in BOOKABLE exactly.
  • A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source. Don't build class names dynamically (`w-[${n}%]` or `bg-${color}`); store the full class, as RATINGS does with "w-[92%]". In className template literals keep a space before each ${...} (`field-error ${message ? "" : " hidden"}`) so classes don't run together.
  • Images or links 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.
  • pnpm lint crashes with contextOrFilename.getFilename is not a function. Keep settings.react.version in eslint.config.mjs.

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 portraits and illustrations. Figtree and Fraunces are licensed under the SIL Open Font License (src/fonts/*-OFL.txt). Brightside Dental, its team and its patients are fictional.

Search documentation

Search the guides and every template's documentation