Overview#
Smile is a bright, friendly website for a family and cosmetic dental practice. It's built for Grin Lane Dental, a
fictional practice in Boise, Idaho, with four dentists, three hygienists and a patient care team. It includes a
five-step booking flow whose time slots come from each clinician's schedule and the length of the visit, a pricing page
with a cost estimator and a payment-plan calculator, an emergency symptom guide, a multi-step new patient form and
filterable treatments and team pages. Rounded Fredoka and Nunito type, a berry, bubble-pink, lemon and mint palette and
Pip the tooth mascot keep it warm and reassuring. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS
v4, generated as a static site, with no other dependencies. This guide covers version 1.0.1 (see template.json).
Live demo: freeframe.dev/templates/smile.
All health content is general information written for a fictional practice. Have a qualified professional review every treatment description, triage rule and price before you publish your own site.
Quick start#
Requirements: Node.js 24 and pnpm.
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm lint |
ESLint |
pnpm typecheck |
nuxt prepare and vue-tsc |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with "Book an appointment" and "Dental emergency?" buttons, live open-now badge, next available checkup card, emergency strip, $99 new patient offer, six treatment cards, why-us cards, four dentists, review carousel, insurance and Grin Club teaser, hours with map, call-to-action band |
/treatments |
app/pages/treatments.vue |
16 treatments filterable by category (?category=), each with what to expect, duration, from-price and a booking link; general-information note; FAQ |
/pricing |
app/pages/pricing.vue |
Fee table (stacks into cards on phones), three Grin Club plans, cost estimator, illustrative payment-plan calculator, insurance help |
/team |
app/pages/team.vue |
Nine team members filterable by role (?role=) and by language, with training, specialties, days in the practice and "Book with" links |
/new-patients |
app/pages/new-patients.vue |
First-visit timeline, what to bring, nervous-patient guide in tabs, five-step patient form with review screen, privacy summary (#privacy) |
/book |
app/pages/book.vue |
Five-step booking flow with a summary sidebar; accepts ?reason= and ?clinician= |
/emergency |
app/pages/emergency.vue |
Call and book buttons, "call 911 first" box, three-question symptom guide, first-aid tips, emergency hours |
/contact |
app/pages/contact.vue |
Contact cards, validated contact form, map, hours, getting here and accessibility (#accessibility) |
| 404 | app/error.vue |
Friendly error page with links (noindex), generated as 404.html |
Project structure#
smile/
app/
app.vue <NuxtLayout> and <NuxtPage> (pages restart when a link opens the page already showing)
error.vue 404 page
layouts/default.vue skip link, header, footer, toast and back to top
pages/ one .vue file per route (index, treatments, pricing, team, new-patients, book, emergency, contact)
components/
SiteHeader.vue sticky header, main menu, mobile menu
SiteFooter.vue footer with contact details, links and office hours
LogoMark.vue the tooth logo mark
AppIcon.vue line icons (<AppIcon name="tooth" />) from app/data/icons.ts
OpenStatus.vue open-now badge
HoursList.vue office hours list with today highlighted
NextCheckup.vue the home page's "Next checkup" card text
ReviewsCarousel.vue home page review carousel
TreatmentsList.vue treatments list with category filter
TeamList.vue team grid with role and language filters
CostEstimator.vue cost estimator (QtyStepper.vue is its - / + stepper)
PaymentPlan.vue payment-plan calculator
BookingFlow.vue booking flow
IntakeForm.vue new patient form
ContactForm.vue contact form
FieldError.vue a field's inline error message
ToastMessage.vue, BackToTop.vue small site-wide helpers
composables/
useValidation.ts form validation and messages
useFilter.ts shared filter state (chip value in the URL)
useEstimate.ts estimator state and totals, shared with the payment plan
usePracticeNow.ts the practice's date and time, updated every minute in the browser
usePageSeo.ts per-page title, description, canonical and Open Graph tags
useCurrentPage.ts, useAsset.ts, useYear.ts, useMounted.ts, useTick.ts
data/
practice.ts PRACTICE, HOURS, CLOSED_DATES, LUNCH, DAY_NAMES, CLINICIANS
booking.ts REASONS (visit types) and BOOKING (slot rules)
pricing.ts FEE_GROUPS, ESTIMATE_ITEMS, MEMBERSHIP, START_MEMBERS, FINANCE
treatments.ts TREATMENT_CATEGORIES and TREATMENTS
team.ts TEAM_ROLES, LANGUAGES and TEAM
triage.ts SYMPTOMS, FLAGS and OUTCOMES for the symptom guide
reviews.ts REVIEWS
nav.ts NAV (main menu)
icons.ts ICONS (line icon shapes)
utils/
time.ts dates and times in the practice's time zone, money()
schedule.ts openStatus(), hoursText(), slotsFor(), firstAvailable()
toast.ts toast() and makeRef()
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the page already showing restart it at the top
stable-build.server.ts reproducible builds
router.options.ts query strings written like the HTML version
assets/css/main.css Tailwind entry: fonts, @theme tokens and component classes
assets/fonts/ Fredoka and Nunito woff2 files and their license (OFL.txt)
public/img/ favicon, hero, map, portraits, og-image.png and its og-image.svg source
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
template.json, README.md, CHANGELOG.md, DOCS.mdapp/data/pricing.ts also has FEE_GROUPS, the fees in the fee table; the table itself is written out in
app/pages/pricing.vue, so keep the two in step if you use FEE_GROUPS elsewhere.
Customizing#
Brand and colors#
All design tokens live in the @theme block at the top of app/assets/css/main.css. Tailwind turns each token into
utilities, so --color-berry-600 becomes bg-berry-600, text-berry-600 and so on.
| Token group | Used for |
|---|---|
--color-berry-50 to --color-berry-950 |
Brand blue-violet: buttons and links (600), dark sections and the footer (950) |
--color-bubble-50 to --color-bubble-800 |
Playful pink: emergency buttons and badges, tags |
--color-lemon-50 to --color-lemon-700 |
Sunny highlight: offer cards, squiggle underline, icon dots |
--color-mint-50 to --color-mint-800 |
Calm green: open-now badge, "included" tags, self-care states |
--color-ink, --color-body, --color-muted |
Headings, body text and secondary text |
--color-line, --color-cream, --color-cloud |
Borders, page background and soft panels |
To change the brand color, replace the berry scale:
@theme {
--color-berry-50: #eef6ff;
--color-berry-100: #d9eaff;
/* ...keep 50 to 950 from light to dark... */
--color-berry-600: #1f6fd1;
--color-berry-950: #0c1f3d;
}Keep the 600 shade dark enough for white button text (at least 4.5:1 contrast). A few colors are written as hex
values in the SVG files and inline SVGs (for example #5146d4 for berry and #1d1a4d for ink, also in
app/components/LogoMark.vue), so search and replace those too. The theme color (#fffaf3, the cream background) is the
theme-color meta tag in app.head in nuxt.config.ts.
Component classes (.btn, .btn-primary, .btn-lemon, .btn-pink, .btn-outline, .card, .chip, .field,
.option, .slot, .cal-day, .faq, .open-pill, .outcome, .squiggle and more) are defined in
@layer components in the same file.
Typography#
Fredoka is the rounded display face for headings (font-display, also applied to h1 to h4 in the base layer) and
Nunito is the body face (font-sans). Both are self-hosted from app/assets/fonts/, declared with @font-face at the
top of app/assets/css/main.css, from the exact woff2 files Google Fonts serves (a latin and a latin-ext file per
family), so text sets exactly like the HTML version and no request goes to Google. The @theme tokens refer to them by
name:
--font-display: "Fredoka", "Nunito", ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
--font-sans: "Nunito", ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;To swap a font, put its woff2 files in app/assets/fonts/, change the @font-face rules and the two tokens.
Content#
Page copy lives in the page files (app/pages/*.vue). Practice data that drives the interactive features lives in
typed files in app/data/:
| File and export | What it holds |
|---|---|
practice.ts PRACTICE |
Name, time zone (America/Boise), time zone label, phone, email and address |
practice.ts HOURS |
Office hours per weekday (0 = Sunday, null = closed), used by every hours list, the open-now badges and the booking slots |
practice.ts CLOSED_DATES, LUNCH |
Whole-practice closures (YYYY-MM-DD) and the lunch break on days longer than six hours |
practice.ts CLINICIANS |
Each clinician's name, short name, role, portrait and working weekdays |
booking.ts REASONS |
Visit types: label, length in minutes, price note, the line shown on step 1, icon, who offers it and minimum notice |
booking.ts BOOKING |
Slot interval, how far ahead people can book and the demo "taken" share |
pricing.ts FEE_GROUPS |
The fees on /pricing as data (the table itself is in app/pages/pricing.vue) |
pricing.ts ESTIMATE_ITEMS, MEMBERSHIP, START_MEMBERS, FINANCE |
Cost estimator treatments and prices, Grin Club prices and discount, payment-plan terms |
treatments.ts |
Category chips and the 16 treatment cards |
team.ts |
Role chips, languages and the nine team cards |
triage.ts |
Symptom guide symptoms, follow-up questions, red flags, tips and outcome wording |
reviews.ts |
Home page reviews |
The office hours lists are generated from HOURS, so they always match the badges and the booking calendar. The
contact page's closure sentence is plain text; update it when you change CLOSED_DATES.
Add a clinician. Add an entry to CLINICIANS, list their id in the who array of each visit type they offer, add
their portrait to public/img/ and a card to TEAM:
// app/data/practice.ts
park: { name: "Dr. Grace Park", short: "Dr. Park", role: "Dentist", img: "img/dr-park.svg", days: [1, 3, 5] },
// app/data/booking.ts
checkup: { label: "Checkup & cleaning", /* ... */ who: ["kowalski", "haddad", "brooks", "park"], notice: 240 },Team cards use role for the role filter (dentist, hygienist or care) and langs for the language filter
(values from LANGUAGES). A card whose href starts with tel: gets a "Call" button; otherwise it's a booking link
such as /book?clinician=park.
Add a treatment. Add an object to TREATMENTS with a category from TREATMENT_CATEGORIES (general,
cosmetic, orthodontics, implants, kids, emergency) and a reason from REASONS for its booking link. The
chip counts update automatically. If the treatment should be in the estimator, add it to ESTIMATE_ITEMS:
{ id: "veneer", name: "Porcelain veneer", price: 1150, qty: 0 },Add included: { per: "adult", count: 2 } (or per: "kid") when Grin Club covers it. Keep the fee table, the
treatment prices and the estimator in step.
Images and artwork#
All artwork is SVG, so it stays sharp and needs no image optimization:
public/img/hero.svg(560 x 520) is Pip the tooth mascot on the home page. The two floating cards over it are HTML.- Team portraits are 240 x 240 SVGs with a rounded square background. To use photos, keep a square crop (at least
480 x 480 pixels), save them in
public/img/and change theimgpaths inTEAMandCLINICIANSso the booking flow shows the same picture. Paths are written without a leading slash (img/dr-okafor.svg) and go throughuseAsset(), so they keep working under a base path. public/img/map.svgis an illustrated map. You can replace it with a static map image or an embedded map<iframe>(that adds an external request).- Smaller illustrations (why-us cards, page headers, 404, nervous-patient card) are inline SVG in the page files.
public/img/favicon.svgis the favicon; the same mark isapp/components/LogoMark.vue.- The social image is
public/img/og-image.png(1200 x 630). Its source ispublic/img/og-image.svg. After editing the SVG, export it to PNG at 1200 x 630, because social sites don't display SVG.
Navigation, header and footer#
The main menu is NAV in app/data/nav.ts; app/components/SiteHeader.vue adds Home and "Dental emergency" to the
mobile menu and marks links to the current page with aria-current="page" (useCurrentPage()). The "Emergency?" pill
shows from 1280 px wide. The footer's link lists are written out in app/components/SiteFooter.vue. To add a page,
create app/pages/<route>.vue with a <main id="main" tabindex="-1"> root and a usePageSeo({ title, description })
call, add the route to routes in nuxt.config.ts, and add it to NAV and the footer.
Features#
Booking flow#
/book has five steps: visit type, clinician, date and time, details and confirmation. The code is
app/components/BookingFlow.vue; the slot rules are in app/utils/schedule.ts.
- Visit type lists
REASONSin order. - Clinician shows "First available" plus the visit type's
wholist with each clinician's role, workdays and next opening. If a preselected clinician doesn't offer the chosen visit type, "First available" is used. - Date and time.
slotsFor(reasonId, clinician, dateKey, now)builds start times everyBOOKING.slotStepminutes inside each clinician's working windows (practiceHOURSon theirdays, minusLUNCHon long days), keeping only times where the whole visit (minutes) fits before the window ends. Times before now plus the visit'snoticeare skipped, as areCLOSED_DATESand anything beyondBOOKING.daysAhead. With "First available", each time is offered by the first free clinician inwhoorder and the slot shows their name. Days with times get a green dot; "First available" jumps to the earliest day and selects its first time. - Demo availability.
BOOKING.demoBusyShare(0.55) marks a stable, pseudo-random share of slots as taken (hash()of clinician, date and time) so the demo looks realistic. Set it to0when you load real availability. - Details are validated (see Forms). The confirmation shows a summary with a reference, what happens next and an "Add to calendar (.ics)" button that downloads a tentative event converted from Boise time to UTC.
- Links in.
/book?reason=emergencypreselects a visit type;/book?clinician=reyespreselects a clinician.
All times are practice times (PRACTICE.timeZone), because patients come in person, whatever the visitor's own time
zone. To move the practice, change timeZone and zoneName. The calendar and slots are worked out in the browser
when the page opens, so the generated site never shows stale times.
Accessibility: each step's heading receives focus when it opens; the progress bar marks the current step with
aria-current="step"; the calendar uses a roving tabindex (arrow keys move by day and week, Home and End jump to
the start and end of the week, Page Up and Page Down move four weeks) and each day has a full label such as
"Thursday, October 15, 2026, 6 times available". Unavailable days use aria-disabled. Errors are announced next to
the step's buttons.
Cost estimator#
app/components/CostEstimator.vue renders a row with a stepper for each entry in ESTIMATE_ITEMS, plus steppers for the
number of adults and kids in the plan (starting values in START_MEMBERS). estimate() compares:
- Pay as you go: quantity x price for every item.
- With Grin Club: yearly membership (
MEMBERSHIP.adultYearper adult,MEMBERSHIP.kidYearper child), items covered byincludedfree up to their yearly count per member, and everything else atMEMBERSHIP.discountoff. With nobody in the plan it shows "—".
The cheaper option gets a "Best value" tag and a sentence explains the saving. Typed quantities apply when the field
is left or Enter is pressed and are kept between 0 and 32 (members 0 to 8). Results are in an aria-live region; each
stepper is a labeled group with "Fewer" and "More" buttons and a number input. useEstimate() in
app/composables/useEstimate.ts keeps the quantities and totals (with useState) and shares them with the payment plan.
Payment-plan calculator#
app/components/PaymentPlan.vue: visitors enter a cost between FINANCE.min and FINANCE.max (or use the slider,
or "Use my estimate from above") and choose a term from FINANCE.terms. calc() uses the standard amortized loan
formula, or a simple division for 0% terms. An amount out of range shows an error and keeps the last result. The rates
are deliberately labeled as made-up examples; replace them with your financing provider's published terms or remove
the section.
Emergency symptom guide#
The guide is written in app/pages/emergency.vue (markup and script), with data in app/data/triage.ts:
- The main problem (one of
SYMPTOMS). - Red flags (
FLAGS; "None of these" clears the others). A flag with level"911"sends people straight to the "Call 911" outcome;"call"sends them to "Call us now". - A yes or no follow-up question from the symptom's
ask. Symptoms withask: nullgo straight to theiroutcome.
Outcomes are 911, call, today and self (OUTCOMES). Each shows its own steps plus the symptom's tip and the
matching buttons (each has a data-outcome-cta list of the outcomes it belongs to). The wording is intentionally cautious and the page repeats that it's general
information, not a diagnosis. If you change the rules, keep a route to emergency services for breathing problems,
spreading swelling and head injuries. Each question's heading receives focus as it opens.
New patient form#
app/components/IntakeForm.vue has five fieldsets (data-intake-step) with Back and Next buttons, a progress bar, a
"None of these" option that clears the other medical history boxes, a typed signature that must match the first and
last name from step 1 (case and spacing ignored), and a review screen with an "Edit" button per section. Each step is
validated before moving on; Enter in a field moves to the next step. See Forms.
Filters#
/treatments (TreatmentsList.vue) and /team (TeamList.vue) share useFilter()
(app/composables/useFilter.ts): chips with aria-pressed and live
counts, an optional "Speaks" select on the team page, an aria-live status ("Showing 3 treatments."), an empty state
with a reset button that returns focus to the first chip. The chip value is kept in the URL (?category= or
?role=), so filtered views can be shared; unknown values show everything. Items are hidden with the hidden
attribute rather than removed, so their ids (/treatments#whitening) always exist.
Office hours and open-now badge#
HoursList.vue renders HOURS (Monday first) and highlights today's row, adding "(today)"
for screen readers; OpenStatus.vue shows "Open now · until 5:00 pm" or "Closed · opens tomorrow 8:00 am" from
openStatus(); NextCheckup.vue finds the first open checkup slot for the home page card. All of them use the practice's
time zone, update every minute (usePracticeNow()) and are filled in by the browser (the badge is hidden until then), so
the generated site never shows the build time.
Tabs, FAQ and carousel#
- The nervous-patient guide in
app/pages/new-patients.vueuses the ARIA tabs pattern (arrow keys, Home and End);TABSandonTabKey()are in the page's script. - FAQ items on
/treatmentsare native<details>elements styled with.faq. - The review carousel (
ReviewsCarousel.vue) is a horizontally scrolling list with scroll snapping and previous and next buttons that disable at each end. It's keyboard-scrollable and never moves on its own.
Scroll reveals, motion and back to top#
Elements with .reveal fade up as they enter the viewport (app/plugins/reveal.client.ts; stagger with
style="--d:120ms"). Revealing one element also reveals everything before it, so jumping with the End key or
an anchor never leaves gaps. Floating cards, the open-now pulse and the card hovers stop for visitors who prefer reduced
motion. To remove the back-to-top button, delete <BackToTop /> from app/layouts/default.vue, the component, and the
.back-to-top styles in app/assets/css/main.css.
Forms#
Every form validates in the browser and shows a success state, but nothing is sent, booked or stored until you
connect it, even though the confirmation screens read as if it had been. They show a short reference (GL- and
four characters) made in the browser by makeRef() in app/utils/toast.ts; if your PMS or form service returns its
own reference, show that instead. Check that the promises on those screens (a confirming text within an hour, a reply
within one business day, answers going to the patient record) match how your practice works.
Validation is useValidation() in app/composables/useValidation.ts. A field is checked when it has
data-validate; the rules come from its attributes, and the wording is in fieldMessage():
| Attribute | Rule |
|---|---|
required |
Must be filled, checked or chosen (data-required-msg sets a custom message) |
type="email" |
Must look like an email address |
data-phone |
At least 10 digits |
data-past-date |
A valid date in the past (date of birth) |
minLength |
Minimum length |
data-match |
Must equal data-match-value (set by the patient form for the signature) |
data-label |
The field name used in messages ("First name is required.") |
Each field binds v-bind="v.invalid(id)" and is followed by <FieldError>. Errors appear under the field (or under the group for radio buttons), are linked with aria-describedby, set
aria-invalid and focus the first problem; they clear as the visitor fixes them.
| Form | File | Validates |
|---|---|---|
| Contact form | app/components/ContactForm.vue |
Name, email, optional phone, topic, message of 10+ characters |
| Booking details | app/components/BookingFlow.vue |
First and last name, email, mobile, new or returning, consent |
| New patient form | app/components/IntakeForm.vue |
Name, date of birth, phone, email, contact preference, visit reason, last visit, how nervous, medical history, consent and signature |
Connecting the contact form#
The contact form is a good fit for a form service such as Formspree. In onSubmit() in
app/components/ContactForm.vue, send the data before showing the thank-you message:
async function onSubmit() {
const f = form.value;
if (!f || !v.validate(f)) return;
const data = new FormData(f);
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return toast("Sorry, that didn’t send. Please call us instead.");
name.value = String(data.get("name")).trim().split(" ")[0] ?? "";
reference.value = makeRef("GL");
sent.value = true;
await nextTick();
done.value?.focus();
}Ask people not to include medical details in this form (the page already does). With pnpm build (a Node.js server
build) you can post to your own server route instead (server/api/contact.post.ts with defineEventHandler).
Connecting the booking flow#
Bookings belong in your practice-management system (PMS). Most offer an online-scheduling API or an embeddable
booking widget. Either replace <BookingFlow /> in app/pages/book.vue with the widget, or keep this flow and:
- Load real open times from the PMS (through your own server, so API keys stay private) and return them from
slotsFor()inapp/utils/schedule.tsin the same{ mins, who }shape. SetBOOKING.demoBusyShareto0. - In
onSubmit()inBookingFlow.vue, POST the visit type, clinician, date, time and details to your server (or a Nuxt server route), which creates the appointment in the PMS. - Show step 5 only after the PMS confirms, and change the confirmation copy to match.
Booking details are health information. Use HTTPS and a vendor that will sign a Business Associate Agreement (BAA) in the US or a Data Processing Agreement (DPA) under the GDPR in the EU and UK.
Connecting the new patient form#
The patient form collects health information, so a general-purpose form service or plain email isn't appropriate:
- US (HIPAA): post it only to a HIPAA-eligible form or intake service that signs a BAA, or use your PMS's own patient intake forms and link to them instead.
- EU and UK (GDPR): health data is special category data. Use a processor with a DPA, keep the explicit consent
checkbox in step 4, collect only what you need and state your retention period in your privacy notice. The
#privacysection ofapp/pages/new-patients.vueis a short summary that points to your full privacy notice; link to yours there.
Replace the demo branch in onSubmit() in IntakeForm.vue with a POST of new FormData(form.value) to that
service. The answers are deliberately never written to localStorage, cookies or anywhere else; keep it that way.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the exact <title>
and description copied from the HTML version, a canonical link, Open Graph tags (og:image is
public/img/og-image.png) and a Twitter card. The home page passes a shorter ogTitle; the 404 page passes
noindex: true. Social networks need absolute URLs, so set your domain when building; it becomes
runtimeConfig.public.siteUrl in nuxt.config.ts:
SITE_URL=https://www.your-domain.com pnpm generateConsider adding a sitemap.xml and robots.txt to public/ listing your routes, and Dentist structured data
(schema.org JSON-LD, with useHead()) on the home page with your real name, address, phone, hours and
geo-coordinates. The template doesn't include structured data because it would describe a fictional practice.
Accessibility#
Built in:
- A skip link, landmarks (
header,nav,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines on every interactive element and color contrast that meets WCAG AA.
- An accessible mobile menu (
aria-expanded,aria-controls; Escape closes it and returns focus). - ARIA patterns: tabs,
aria-pressedfilter chips and calendar days,aria-currentfor the current page and booking step,aria-liveregions for filter counts, estimates and slot headings. - Forms with real labels, grouped radio buttons and checkboxes in
fieldsetandlegend, inline errors linked witharia-describedby, and focus moved to the first error or the next step's heading. - Decorative SVGs are hidden with
aria-hidden="true"; meaningful images have alt text. - Reduced motion turns off reveals, floating cards, the pulse and hover lifts.
When you edit:
- Keep exactly one
<h1>per page and don't skip heading levels. - Give every new input a
<label>and adddata-validatewhen it needs checking. - Check contrast when you change colors, especially white text on the 600 shades.
- Keep the emergency guidance visible and plain: it's the content people need most when they're stressed.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (pricing.html,
linked as /pricing), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json,
which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages,
GitHub Pages, S3, Vercel). Hosts that don't map /pricing to pricing.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs. Serve the site over HTTPS, especially once forms are
connected.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/smile pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load;
replace it with typescript@7 once they support it.
Troubleshooting#
| Problem | Fix |
|---|---|
| The open-now badge looks wrong | It uses PRACTICE.timeZone, HOURS and CLOSED_DATES in app/data/practice.ts, not the visitor's clock. Check the zone is an IANA name such as America/Denver |
| The booking calendar shows no times | Check that the visit type's who clinicians work on days that are open in HOURS, that the visit minutes fit inside the office hours, and that the dates aren't in CLOSED_DATES or beyond BOOKING.daysAhead. Late in the day, today may have no times because of the visit's notice |
| Times in the downloaded .ics file are an hour off | wallToInstant() in app/utils/time.ts converts practice time to UTC with PRACTICE.timeZone; make sure it's a valid IANA zone |
| A filter link opens unfiltered | The parameter must match a chip value exactly, for example /treatments?category=orthodontics or /team?role=hygienist |
| A new treatment or team member never shows | Its category or role must be one of the chip values, and team langs must use LANGUAGES values |
| The social preview has no image | Build with SITE_URL set to your domain so og:image is an absolute URL |
| A hydration warning appears in the console | Anything that reads the clock, the URL or storage must run in onMounted() or an event handler, never while rendering |
| Images or styles are missing under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use useAsset() for files in public/ |
| Content stays invisible after editing | .reveal elements are hidden until app/plugins/reveal.client.ts reveals them, and Vue drops the is-visible class it adds when an element also has a :class binding, so keep .reveal off those; check the browser console for errors |
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. Fredoka and
Nunito are licensed under the SIL Open Font License 1.1 (app/assets/fonts/OFL.txt). The practice, people,
reviews and prices are fictional.