Freeframe

Documentation

Stay

v1.0.1Updated Nov 11, 2026An elegant boutique hotel website with live prices, availability and booking.

Overview#

Stay is a complete website for a small hotel or resort, shown as Casa Marea, a fictional 22-room boutique hotel in a 1903 villa above Narragansett Bay in Newport, Rhode Island. Guests can pick dates on the home page, filter the rooms, open a room with a photo lightbox, a live price and a two-month availability calendar, read the menus, browse experiences, copy an offer code and book in five steps with a full price breakdown. Rooms, extras, offers, experiences and menus live in one typed data file, and the pricing rules in one TypeScript file. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.

Live demo: freeframe.dev/templates/stay

Quick start#

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

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
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 typecheck nuxt prepare, then vue-tsc
pnpm lint ESLint (the flat config from @nuxt/eslint)
pnpm build:demo Build for /demos/nuxt/stay into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ app/pages/index.vue Hero with the booking bar, welcome figures, three featured rooms, experiences, restaurant, reviews, getting here, offers banner
/rooms app/pages/rooms.vue Six rooms with filters by view, beds and guests (kept in the URL), "In every room"
/room?room=<id> app/pages/room.vue One room: gallery and lightbox, live price, details, amenities, availability calendar, other rooms
/dining app/pages/dining.vue Three places to eat, menus in tabs, table request form
/experiences app/pages/experiences.vue Eight experiences with a filter (also from #spa, #sea...), weddings and events
/offers app/pages/offers.vue Three offers with copy-code buttons, terms and "Book with this offer"
/contact app/pages/contact.vue Map and directions, contact form (prefilled from ?topic= and ?about=), FAQ
/booking app/pages/booking.vue Five-step booking flow with a live summary and a confirmation
404 app/error.vue Page not found (generated as 404.html)

A room is chosen with a query string (/room?room=suite) rather than a route parameter, so one generated page serves every room and the URLs match the HTML version.

Project structure#

app/
├── app.vue                   <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see "Links to the same page")
├── layouts/default.vue       skip link, header, page, footer, toast and back-to-top button
├── pages/                    one file per page (index.vue, rooms.vue, room.vue, booking.vue...)
├── error.vue                 the 404 page
├── components/
│   ├── SiteHeader.vue        logo, menu, "Book a stay", mobile menu
│   ├── SiteFooter.vue        address, link columns, newsletter form and copyright year
│   ├── BookingBar.vue        the home page's dates-and-guests form
│   ├── PriceBreakdown.vue    a stay's price, line by line (room page and booking summary)
│   ├── MenuTabs.vue          the dining menus in tabs
│   ├── LogoMark.vue          the sun-and-waves mark
│   ├── ToastMessage.vue      the status message at the bottom of the screen
│   └── BackToTop.vue         the back-to-top button
├── composables/
│   ├── usePageSeo.ts         title, description, canonical URL and Open Graph tags
│   ├── useValidatedForm.ts   inline validation and success messages for the table, contact and newsletter forms
│   └── useAsset.ts           files in public/ with the base path
├── data/
│   ├── hotel.ts              rooms, extras, offers, amenities, experiences, menus, FAQ, contact details
│   └── nav.ts                header and footer links
├── utils/
│   ├── booking.ts            dates, prices, offers, availability and the saved booking draft
│   ├── fields.ts             setFieldError(): an inline error and aria-invalid
│   ├── safeStorage.ts        localStorage with an in-memory fallback
│   └── toast.ts              toast("...")
├── plugins/
│   ├── reveal.client.ts      scroll reveals
│   ├── same-page.client.ts   links to the page already showing, and scrolling to the top
│   ├── keep-hash.client.ts   keeps the #hash exactly as typed (the experiences filter reads it)
│   └── stable-build.server.ts  reproducible builds
├── router.options.ts         query strings written like the HTML version
└── assets/
    ├── css/main.css          Tailwind, brand tokens, components and @font-face rules
    └── fonts/                Bodoni Moda and Jost (woff2) and their licenses
public/img/                   SVG artwork, favicon and social image
nuxt.config.ts                base path, head tags, SITE_URL, prerendered routes, Tailwind

Customizing#

Brand and colors#

All design tokens live in the @theme block of app/assets/css/main.css:

Token Used for
--color-linen Page background
--color-shell, --color-sand Panels and deeper panels
--color-line Borders
--color-ink Text and dark sections
--color-sea Secondary dark (selected states, back-to-top)
--color-mist Muted text
--color-coral, --color-coral-dark Accent (eyebrows, focus rings, errors)
--color-olive, --color-sun Decorative details
--color-foam Light text on dark backgrounds

To change the accent:

css
@theme {
  --color-coral: #2f6f5e;
  --color-coral-dark: #245748;
}

Tailwind rebuilds as you save in pnpm dev. Keep text at WCAG AA contrast against its background. The checkboxes in app/pages/booking.vue use accent-[#1d4b57]; search for the old hex when you change --color-sea. The browser theme color is the theme-color meta tag in nuxt.config.ts.

Typography#

Bodoni Moda (headings, --font-display) and Jost (body, --font-sans) are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css. They are the same Latin files Google Fonts serves, so text sets exactly like the HTML version. To swap a font, put its woff2 file in app/assets/fonts/, change the @font-face rule and the family name in the matching @theme token.

Content#

Copy that doesn't change is markup in each page in app/pages/. The data behind the interactive parts is in app/data/hotel.ts:

Export What it holds
HOTEL Name, place, address, phone and email
ROOMS Each room: id, name, view (sea, garden or pool), bed (king or twin), capacity, size (sq ft), rate (per night, US dollars), short, description, highlights
AMENITIES Listed on every room page after the room's own highlights, and under "In every room"
EXTRAS Booking extras: id, name, price, unit, text
PROMOS Offer codes and their rules (see Offer codes)
EXPERIENCES The experiences page: id, type (Spa, Sea, Land or Events), name, time, price, text, img
MENUS The dining menus: tab name → [dish, description, price] rows
FAQS The contact page's questions and answers
VIEW_LABEL, BED_LABEL Labels for the view and bed values

To add a room, add it to ROOMS:

ts
{
  "id": "loft",
  "name": "Garden Loft",
  "view": "garden",
  "bed": "king",
  "capacity": 2,
  "size": 360,
  "rate": 290,
  "short": "A split-level room under the eaves.",
  "description": "…",
  "highlights": ["Mezzanine bedroom", "Garden patio", "Rain shower"]
},

Then add three images to public/img/: room-loft.svg, room-loft-terrace.svg and room-loft-bath.svg (the room page, the booking flow and the "Other rooms" cards build their paths from the id). The room page, the booking flow and prices pick the room up straight away. The room cards on index.vue and rooms.vue are written as markup: copy an <article data-room-card …> block, set its data-view, data-bed and data-capacity, and on rooms.vue its :hidden="!fits('garden', 'king', 2)" to the same values so the filters work. Keep the prices on those cards in step with rate when you change it.

The extras list on the booking page, the offer cards, the experiences, the menus and the FAQ are all rendered from the data file, so editing it is enough.

Images and artwork#

All artwork is original SVG in public/img/:

Files Aspect ratio Where
hero-coast.svg 16:10 (1600 × 1000) Home hero
room-<id>.svg, room-<id>-terrace.svg, room-<id>-bath.svg 16:10 Room cards, room gallery, booking flow
pool.svg, spa.svg, boat.svg, dining.svg, breakfast.svg, bar.svg, events.svg 16:10 Feature cards and pages
map.svg 3:2 Home and location pages
favicon.svg square Browser tab icon
og-image.svg / og-image.png 1200 × 630 Social sharing image (the SVG is the editable source)

To use photos, export them at the same aspect ratio (1600 × 1000 works everywhere) and change the paths. Files in public/ are always written through useAsset(), which adds the base path:

<img :src="asset('img/room-sea.jpg')" alt="Sea View Room: bedroom" width="1600" height="1000">

The room photos' paths are built in app/pages/room.vue (photos), app/pages/booking.vue (the room step and summaryImg) and app/pages/offers.vue (ART): change the extension there too. Keep meaningful alt text.

The menu links are NAV in app/data/nav.ts; the mobile menu adds Home first. The footer's two link columns are FOOTER_LINKS in the same file. The header (SiteHeader.vue) marks the link to the current page with aria-current="page", which underlines it on desktop and colors it in the mobile menu.

To add a page:

  1. Create app/pages/spa.vue with a root <main id="main" tabindex="-1" class="flex-1 focus:outline-none"> and a usePageSeo({ title, description }) call.
  2. Add "/spa" to routes in nuxt.config.ts, so it is generated.
  3. Add { to: "/spa", label: "Spa" } to NAV (and to FOOTER_LINKS if you like). Link to it with <NuxtLink to="/spa">.

Features#

The booking logic shared by the booking bar, the room page and the booking flow is in app/utils/booking.ts. Nuxt imports its functions (quote(), checkDates(), checkPromo(), readDraft()...) automatically in every component. Everything that depends on today's date, the URL or storage runs after the page has loaded (onMounted()), so the generated HTML always shows the same text and never mismatches.

Booking bar#

app/components/BookingBar.vue, in the home page's hero. It:

  • starts from the saved draft (or two weeks from today for three nights, two adults);
  • keeps the departure after the arrival when the arrival changes;
  • validates with checkDates(): arrival can't be in the past, departure must be after arrival, and stays are limited to MAX_NIGHTS (21);
  • on submit, saves the stay and opens /booking?checkin=…&checkout=…&adults=…&children=….

Errors appear under each field (the element named by its aria-describedby), the field gets aria-invalid, and focus moves to the problem.

Room filters#

/rooms has three selects inside <form data-room-filters>: view, beds and guests (rooms whose capacity is at least the number chosen). The cards that don't match are hidden, the live count ([data-room-count], announced to screen readers) updates and the empty state shows when nothing matches. "Clear filters" shows every room. The filters are written into the address bar, so /rooms?view=sea&guests=3 can be shared; values the menus don't offer are ignored.

Room page#

/room?room=<id> shows one room. The generated HTML shows the first room (Sea View Room); the room from the URL is filled in as soon as the page loads, along with the page title. An unknown id shows the first room. Adult counts above the room's capacity are disabled, and the page lists three other rooms.

Photo lightbox#

The main photo and the three thumbnails ([data-gallery-open]) open a native <dialog data-lightbox> with the bedroom, terrace and bathroom pictures. ← and → and the Previous and Next buttons move between photos, Esc, the close button or a click on the backdrop closes it, and the dialog keeps focus inside while it's open and returns it to the button that opened it. The caption ("Photo 2 of 3: private terrace") is read out as the photo changes.

Prices#

quote(stay) in app/utils/booking.ts prices a stay. The rules are constants at the top of the file:

Constant Default Meaning
WEEKEND_UPLIFT 0.15 Friday and Saturday nights cost 15% more
EXTRA_ADULT 45 Per night, for each adult beyond two
EXTRA_CHILD 25 Per night, for each child once the room already has two guests
VAT_RATE 0.14 Estimated tax added to the total: Rhode Island sales tax (7%), state hotel tax (5%) and local hotel tax (2%), as of 2026. Check current rates with the Rhode Island Division of Taxation. Shown as "Estimated sales and hotel tax (14%)"; change that label in PriceBreakdown.vue too
MAX_NIGHTS 21 Longest stay bookable online

How a total is built:

  1. Each night costs the room rate, plus WEEKEND_UPLIFT on Fridays and Saturdays (rounded to whole dollars).
  2. Extra guests are added per night.
  3. Extras are added: breakfast per guest per night, every other extra once per stay.
  4. A valid offer code takes its percentage off the room total only (not extras or extra guests).
  5. Tax (VAT_RATE) is worked out on that subtotal and added. Rates and extras are shown before tax, and the breakdown says so.

For example, the Sea View Room ($360) for three nights from a Wednesday with two adults is $1,134 (one Friday at $414), plus $158.76 tax, for a total of $1,292.76. PriceBreakdown.vue shows the result on the room page and in the booking summary. To change the currency, edit money() (it formats with en-US and a $ sign).

Availability calendar#

The room page shows two months ([data-calendar]), from the arrival month. Booked nights are struck through and can't be an arrival; click an arrival date, then a departure date, and the price updates. A stay that runs into a booked night is refused with a message in [data-cal-status], which screen readers announce. The arrows move a month at a time (not before this month), and typing an arrival date moves the calendar to its month.

Availability comes from isBlocked(roomId, iso) in app/utils/booking.ts, a deterministic demo pattern. Replace it with your reservations system or channel manager. The calendar renders synchronously, so the simplest approach is to fetch the booked nights once into a Set and check it:

ts
const BOOKED = new Set<string>(); // fill from your API, e.g. "sea:2026-11-02"
export function isBlocked(roomId: string, iso: string) {
  return BOOKED.has(`${roomId}:${iso}`);
}

firstBlocked() checks every night of a stay with isBlocked(), so the room page and the booking flow follow the same data.

Booking flow#

/booking (app/pages/booking.vue) is one <form data-booking-form> with five <fieldset data-step> steps:

  1. Dates and guests, checked with checkDates(). If the guests change and the chosen room no longer sleeps them, fitRoomToGuests() switches to the first room that does and the summary says so ("The Sea View Room sleeps up to 2 guests, so we've switched you to the Marea Suite…"). If no room sleeps that many, the summary shows no price and asks for fewer guests.
  2. Room: every room with its price for the dates; rooms that are too small or booked on any night are disabled with the reason.
  3. Extras: the checkboxes rendered from EXTRAS.
  4. Guest details: first and last name, email and phone (validated inline), arrival time and special requests.
  5. Review: the stay, the offer code field with "Apply" and the cancellation policy checkbox.

The summary beside the form shows the full price. The step list marks the current step with aria-current="step", focus moves to each step's legend, and Back keeps everything filled in. Arriving with a room in the URL (from a room page) starts on step 2. Every change is saved to the draft (see Saved data).

The last step shows "Booking received" with a reference such as CM-7K2Q (made by makeRef("CM"): four characters with no 0/O or 1/I) and clears the draft. Nothing is booked until you connect it: confirm() in app/pages/booking.vue is the place to send the stay (state.value) and the guest details (form) to your booking engine and show the reference it returns. Check prices and availability again on your server: anything calculated in the browser can be changed by the visitor. Change the "within a day" wording to match how you confirm bookings.

Offer codes#

Each entry in PROMOS has:

Field Meaning
code What guests type (any case)
name Shown in messages and the price breakdown
percent Discount off the room total
minNights Minimum stay
advanceDays Minimum days between today and arrival (0 for none)
rooms Room ids the code applies to ([] for all rooms)
summary, terms The offer card's text

checkPromo(code, stay) explains why a code doesn't apply ("Stay longer needs a stay of at least 4 nights."). A code can come in the URL (/booking?promo=LONGSTAY) and is checked on the last step. The offers page renders one card per code; its picture is set in ART in app/pages/offers.vue.

Copy offer codes#

"Copy code" copies the code to the clipboard and says so in the toast (toast() in app/utils/toast.ts, shown for 2.6 seconds). The clipboard needs HTTPS (or localhost); elsewhere, or if the browser refuses, the toast shows the code instead.

Dining menu tabs#

app/components/MenuTabs.vue renders MENUS as an accessible tab set. ← and → move between tabs (wrapping), Home and End jump to the first and last, and only the selected tab is in the Tab order. Add a key to MENUS to add a tab.

Experiences filter#

The All / Spa / Sea / Land / Events buttons (aria-pressed) filter the cards and update the count. A hash opens a type directly: /experiences#sea. Each card's "Ask the concierge" opens the contact form with the experience named.

Contact prefill#

/contact?topic=events&about=boat selects the topic and starts the message ("Hello, I’d like to ask about a private boat to Jamestown."). The about keys and their wording are ABOUT in app/pages/contact.vue; topic must match one of the topic select's <option> values.

Header, mobile menu and scroll reveals#

  • The header gains a border and a shadow once the page scrolls.
  • The menu button (#menu-toggle) opens #mobile-menu, updates aria-expanded and its label, and stops the page behind from scrolling. Esc closes it and returns focus to the button; following a link or widening the window to desktop size closes it too.
  • Elements with the reveal class fade in as they scroll into view (app/plugins/reveal.client.ts). Revealing one also reveals everything above it, so jumping to the end of a page never leaves content hidden. With reduced motion, or without JavaScript, everything shows straight away. Never put reveal on an element with a :class binding: the plugin adds is-visible with classList.

In the HTML version every link loads a page. Here links move between pages without a reload, so app/plugins/same-page.client.ts makes them behave the same: a link to the page already showing (the Rooms link on a filtered rooms page) starts it again from the top, and another room from a room page opens at the top.

Back to top#

app/components/BackToTop.vue appears after the visitor scrolls about one screen. It scrolls to the top (instantly with reduced motion), moves keyboard focus to <main>, lifts itself above the toast, stays hidden while the mobile menu is open, and hides while it would cover elements marked data-back-to-top-hide (the room page's price card and the booking summary). Mark your own fixed elements at the bottom of the screen with data-back-to-top-avoid to keep the button above them. To remove it, delete <BackToTop /> from app/layouts/default.vue and the .back-to-top rules in main.css.

The footer shows the year written in the source (2026) and replaces it with the current year when the page loads.

Forms#

Form Page Validates
Booking bar (data-booking-bar) / Dates
Room price (data-room-price) /room Dates, capacity, availability
Booking (data-booking-form) /booking Each step (see above)
Table request (data-form="table") /dining Date from today, name, email
Contact (data-form="contact") /contact Name, email, a message of at least 10 characters
Newsletter (data-form="newsletter") Footer of every page Email

The table, contact and newsletter forms use useValidatedForm() (app/composables/useValidatedForm.ts): fields are checked when you leave them and again as you type once they show an error; on submit, errors appear under each field, focus moves to the first one, and the form's [data-form-success] message is shown. The table and contact messages include a reference (CM- and four characters, in [data-form-ref]) and say the reply comes within a day; the newsletter message asks the subscriber to look out for a confirmation email.

The forms don't send anything until you connect them. To use a form service such as Formspree, replace the marked comment in the submit handler of useValidatedForm():

ts
// To send for real: fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(el) })
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(el),
  headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your message could not be sent. Please try again or email us.");

(make the submit listener async), and keep the lines after it that reset the form and show the message. To send each form somewhere else, branch on el.dataset.form (table, contact or newsletter). If the service returns a reference, show it in [data-form-ref] instead of makeRef("CM").

To run your own code instead, add a server route such as server/api/booking.post.ts, build with pnpm build (a Node.js server) and post to /api/booking.

Saved data (localStorage)#

Key Shape
casa-marea:booking:v1 { checkin, checkout, adults, children, room, extras, promo }: the booking in progress

The booking bar, the room page and the booking flow share this draft. Every read goes through sanitizeDraft():

  • dates must be real YYYY-MM-DD dates;
  • adults is kept to 1–4 and children to 0–2, whole numbers only;
  • room and extras must be known ids (duplicates are dropped);
  • promo must be 3–20 letters or digits.

Anything invalid (including text that isn't JSON) is dropped and the stored value is repaired, so a corrupt or edited value never breaks a page. A draft with dates in the past starts two weeks from today again. Values in the URL take priority over the draft. When the booking flow restores a room that can't sleep the restored guests, guests from the URL keep their number and get the first room that sleeps them; otherwise the guests are reduced to the room's capacity (adults first). The draft is cleared after a confirmed booking. To reset it by hand, run localStorage.removeItem("casa-marea:booking:v1") in the browser console.

Storage goes through safeStorage (app/utils/safeStorage.ts): when the browser blocks it (a sandboxed frame, strict privacy settings) or it is full, the draft is kept in memory for the rest of the visit instead.

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which writes the title, the meta description, og:title, og:description, og:image, a summary_large_image Twitter card, og:url and a canonical link. The room page's title follows the room (Marea Suite — Casa Marea).

  • Canonical, og:url and og:image are absolute: SITE_URL (default https://casamarea.example) plus the base path. Set it to your domain when you build:

    sh
    SITE_URL=https://www.your-hotel.com pnpm generate
  • The 404 page passes noindex: robots noindex, and no canonical or og:url.

  • For a sitemap, add public/robots.txt and a sitemap.xml (or the @nuxtjs/sitemap module). Consider Hotel structured data (JSON-LD, with useHead()) on the home page with your address, phone and price range. The room page is one URL with ?room=; to give each room its own indexable page, create app/pages/rooms/[id].vue and list each room in routes.

Accessibility#

Built in:

  • a "Skip to content" link and landmarks (header, nav, main, footer) on every page;
  • visible focus rings (:focus-visible in main.css);
  • one h1 per page;
  • form errors linked with aria-describedby and aria-invalid, and focus moved to the first error;
  • tabs, the photo dialog and the step list follow the standard patterns (role="tab", native <dialog>, aria-current="step"), and the filter buttons use aria-pressed;
  • live regions for filter counts, the price, calendar status, the lightbox caption and the toast;
  • reduced motion respected by reveals, smooth scrolling, the hero zoom and the back-to-top button;
  • text colors that meet WCAG AA contrast.

When editing, keep one h1 per page, give every new input a <label>, keep alt text meaningful, and check contrast if you change colors.

Building and deploying#

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

nuxt generate prerenders every route in routes in nuxt.config.ts as <page>.html (rooms.html, linked as /rooms), 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 /rooms to rooms.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/hotel 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#

  • The room page always shows the Sea View Room. The URL needs ?room= with an id from ROOMS; unknown ids show the first room. The generated HTML always shows the first room; the right one appears once the page has loaded.
  • An offer code says it doesn't apply. Check its minNights, advanceDays and rooms in PROMOS; the message names the rule that failed.
  • Prices on the room cards don't match the booking total. The cards on the home and rooms pages are markup; the booking uses rate plus weekend, guest and extra charges. Update the cards when you change rates.
  • The booking flow starts with old dates or guests. It restores the saved draft; remove casa-marea:booking:v1 (see Saved data).
  • Copying a code shows the code instead. The clipboard needs HTTPS or localhost.
  • Images or links break in a sub-folder. Build with DEMO_BASE_PATH, and write files in public/ with useAsset() (NuxtLink adds the base path itself).
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.

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. Bodoni Moda and Jost are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Casa Marea, its staff and its guests are fictional.

Search documentation

Search the guides and every template's documentation