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.
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.devCustomising#
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):
@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.
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):
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:
{ 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(PAYMENTand the privacy section insrc/app/patients/page.tsx); - the directions and accessibility notes on
/contact, and the topics inContactForm.tsx(TOPICS); TRAININGon/team, and the monthly price and Dr. Alvarez card in the/servicesidebar;- 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:
<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.
Navigation, header and footer#
- Header links are
NAVinsrc/data/nav.ts; the mobile menu usesMOBILE_NAV(Home plusNAV).SiteHeader.tsxrenders both and marks the current section witharia-current="page"(the/serviceguide 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, andHoursList. - To add a page, create
src/app/<route>/page.tsx, exportmetadata = pageMetadata(title, description), start it withPageHero, and add aNAV(and maybeFOOTER_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:
OpenStatusshows "Open now · closes at 7:00 pm" or "Closed now · opens tomorrow at 8:00 am" fromopenStatus(), in a.open-pillwithdata-open="true"/"false"for colour androle="status". Before hydration it reads "Checking today's hours...".HoursListprints Monday to Sunday fromHOURSwithhoursText(), and marks today's row withdata-today="true"andaria-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 fromnextFreeSlot(), 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):
- Treatment: from
BOOKABLE, with each treatment's length. - Clinician: the treatment's
wholist with portraits and working days, plus "First available". - Date and time: a week of day buttons (up to
WEEKS_AHEADweeks, starting at the first week with free days) and the free start times for the chosen day. - Your details:
first,last,email,phone,dob,insurance(INSURANCE_OPTIONS),returning,notes(optional, 500 characters) and the requiredconsentcheckbox. - 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_STEPminutes from opening, as long as the appointment ends by closing time; - the
LUNCHwindow is skipped on weekdays (not Saturdays); - nothing sooner than
MIN_NOTICE_MINUTESfrom now; - only clinicians whose
daysinclude 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:
// 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.
Insurance search#
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:
{ 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) |
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:
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
metadatainsrc/app/layout.tsx. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts. The title is used as is, and the Open Graph image isasset("/img/og-image.png")withsiteNamefromPRACTICE.name. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tohttps://example.com). Set it when you build so social images get absolute URLs:
NEXT_PUBLIC_SITE_URL=https://www.your-practice.com pnpm buildFor 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 oneh1per page; - larger base text, high contrast and thick 3px focus rings (
:focus-visibleinglobals.css); - the wizard's fieldsets, legends, focus management and
aria-current="step", with descriptive day labels; aria-pressedon filter chips, days and times;aria-expandedon the menu button;aria-currentfor 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 directoryout. - 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
TIMEZONEinsrc/data/practice.tsto 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
HOURSbyhoursText(), so they update automatically, as do the badge and slots. Hand-written copy doesn't: check the "Hours that fit" reason inREASONS(src/data/home.ts), the step 3 hint inBookingWizard.tsx, and theNextFreeSlotplaceholder ("Tomorrow, 9:30 am") shown before hydration. - A treatment has no free days. Check that its
whoclinicians work on open days (daysinCLINICIANS) and that itsminutesfit within the hours outsideLUNCH. ?service=doesn't preselect a treatment. The value must match anidinBOOKABLEexactly.- 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, asRATINGSdoes with"w-[92%]". InclassNametemplate 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_PATHset, and reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews use
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. pnpm lintcrashes withcontextOrFilename.getFilename is not a function. Keepsettings.react.versionineslint.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.