Freeframe

Documentation

Smile

v1.0.1Updated Nov 13, 2026A bright, friendly website for a family and cosmetic dental practice, with online booking, a cost estimator and an emergency symptom guide.

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.

sh
pnpm install
pnpm dev

Open 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.md

app/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:

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

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

ts
// 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:

ts
{ 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 the img paths in TEAM and CLINICIANS so the booking flow shows the same picture. Paths are written without a leading slash (img/dr-okafor.svg) and go through useAsset(), so they keep working under a base path.
  • public/img/map.svg is 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.svg is the favicon; the same mark is app/components/LogoMark.vue.
  • The social image is public/img/og-image.png (1200 x 630). Its source is public/img/og-image.svg. After editing the SVG, export it to PNG at 1200 x 630, because social sites don't display SVG.

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 REASONS in order.
  • Clinician shows "First available" plus the visit type's who list 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 every BOOKING.slotStep minutes inside each clinician's working windows (practice HOURS on their days, minus LUNCH on long days), keeping only times where the whole visit (minutes) fits before the window ends. Times before now plus the visit's notice are skipped, as are CLOSED_DATES and anything beyond BOOKING.daysAhead. With "First available", each time is offered by the first free clinician in who order 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 to 0 when 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=emergency preselects a visit type; /book?clinician=reyes preselects 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.adultYear per adult, MEMBERSHIP.kidYear per child), items covered by included free up to their yearly count per member, and everything else at MEMBERSHIP.discount off. 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:

  1. The main problem (one of SYMPTOMS).
  2. 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".
  3. A yes or no follow-up question from the symptom's ask. Symptoms with ask: null go straight to their outcome.

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.

  • The nervous-patient guide in app/pages/new-patients.vue uses the ARIA tabs pattern (arrow keys, Home and End); TABS and onTabKey() are in the page's script.
  • FAQ items on /treatments are 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:

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

  1. Load real open times from the PMS (through your own server, so API keys stay private) and return them from slotsFor() in app/utils/schedule.ts in the same { mins, who } shape. Set BOOKING.demoBusyShare to 0.
  2. In onSubmit() in BookingFlow.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.
  3. 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 #privacy section of app/pages/new-patients.vue is 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:

sh
SITE_URL=https://www.your-domain.com pnpm generate

Consider 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-pressed filter chips and calendar days, aria-current for the current page and booking step, aria-live regions for filter counts, estimates and slot headings.
  • Forms with real labels, grouped radio buttons and checkboxes in fieldset and legend, inline errors linked with aria-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 add data-validate when 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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

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

sh
DEMO_BASE_PATH=/smile pnpm generate

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

Search documentation

Search the guides and every template's documentation