Freeframe

Documentation

Stay

v1.0.7Updated Oct 18, 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 the sea in Porto Lume, Liguria. It has an immersive home page with a booking bar, filterable rooms, a room page with live prices and an availability calendar, dining menus, experiences, offers with promo codes, a location page and a five-step booking flow. This is the Next.js version (1.0.5): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.

Live demo: freeframe.dev/templates/stay

Quick start#

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

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm build Static site in out/
pnpm start Serve out/ locally (npx serve out)
pnpm lint ESLint (Next.js core-web-vitals and TypeScript rules)
pnpm typecheck Generate route types (next typegen), then type-check with tsc (TypeScript 7)
pnpm build:demo Build for /demos/nextjs/stay into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Hero with the booking bar, intro, three featured rooms, pool/spa/boat cards, dining, offers, reviews, location
/rooms src/app/rooms/page.tsx All rooms with filters by view, beds and guests, kept in the URL
/room?room=<id> src/app/room/page.tsx One room: gallery and lightbox, details and amenities, live price, availability calendar
/dining src/app/dining/page.tsx Restaurant, breakfast and bar, menus in tabs, table request form
/experiences src/app/experiences/page.tsx Experiences with a category filter
/offers src/app/offers/page.tsx Packages with promo codes and a copy button
/contact src/app/contact/page.tsx Illustrated map, getting here, FAQ, contact form (prefilled from the URL)
/booking src/app/booking/page.tsx Five steps: dates, room, extras, guest details, review; demo confirmation
404 src/app/not-found.tsx Page not found

The room is chosen with a query string (/room?room=suite) rather than a dynamic segment, so the whole site can be a static export.

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
│   └── opengraph-image.png   default social image
├── components/               BookingBar, RoomsBrowser, RoomCard, RoomView, BookingFlow, Breakdown,
│                             MenuTabs, ExperiencesGrid, CopyCodeButton, ContactPrefill, ValidatedForm,
│                             SiteHeader, SiteFooter, Toast, RevealObserver, BackToTop, Art, Icon, Logo, Year
├── fonts/                    Bodoni Moda and Jost (Latin .woff2) with their OFL licences
├── data/
│   ├── hotel.ts              hotel details, rooms, amenities, extras, offers, experiences, menus
│   └── nav.ts                main navigation
└── lib/
    ├── booking.ts            dates, prices, offers, availability and the saved booking draft
    ├── asset.ts              asset(): prefixes public files with the base path
    └── metadata.ts           pageMetadata(): title, description and Open Graph per page
public/img/                   SVG artwork and og-image.png
next.config.ts                static export and optional base path

Customising#

Brand and colours#

Design tokens live in the @theme block at the top of src/app/globals.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 (buttons, 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
css
@theme {
  --color-coral: #2f6f5e;
  --color-coral-dark: #245748;
}

Tailwind compiles on the fly in pnpm dev and pnpm build, so there's no separate CSS step. A few components use hex values directly (for example accent-[#1d4b57] on checkboxes); search for the old hex when you change --color-sea. The browser theme colour is viewport.themeColor in src/app/layout.tsx.

Typography#

Fonts are self-hosted with next/font/local in src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML version, so text sets exactly like it, and no request goes to Google from the visitor's browser. Their SIL Open Font License texts are the *-OFL.txt files beside them.

tsx
const bodoni = localFont({
  src: [
    { path: "../fonts/BodoniModa-latin.woff2", weight: "400 600", style: "normal" },
    { path: "../fonts/BodoniModa-Italic-latin.woff2", weight: "400 500", style: "italic" },
  ],
  variable: "--font-bodoni",
  display: "swap",
  adjustFontFallback: false,
  fallback: ["Bodoni Moda"],
  declarations: [{ prop: "font-family", value: "Bodoni Moda" }],
});
const jost = localFont({
  src: "../fonts/Jost-latin.woff2",
  variable: "--font-jost",
  weight: "300 600",
  display: "swap",
  adjustFontFallback: false,
  fallback: ["Jost"],
  declarations: [{ prop: "font-family", value: "Jost" }],
});

--font-display and --font-sans in globals.css use those variables. To swap a font, add its .woff2 (and licence) to src/fonts/ and point src at it, or import another family from next/font/google; give it the same variable name, and update the fallback names in the tokens.

Content#

Most content that repeats or drives features lives in src/data/hotel.ts:

Export What it holds Used by
HOTEL Name, place, address, phone, email SiteHeader
ROOMS Rooms (Room type): id, name, view ("sea" | "garden" | "pool"), bed ("king" | "twin"), capacity, size, rate (per night, euro), short, description, highlights /rooms, /room, booking
AMENITIES Amenities shown on every room page RoomView
EXTRAS Booking extras: id, name, price, unit, text BookingFlow, quote()
PROMOS Promo codes and their rules checkPromo()
EXPERIENCES Experiences (type: "Spa" | "Sea" | "Land" | "Events") ExperiencesGrid
MENUS Dining menus keyed by tab name: [dish, description, price] MenuTabs
VIEW_LABEL, BED_LABEL Display labels cards and room page

To add a room, add an object to ROOMS:

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

and add public/img/room-loft.svg, room-loft-terrace.svg and room-loft-bath.svg. The rooms page, room page, filters and booking flow pick it up automatically.

Some content is written directly in the page files and must be edited there:

  • the three featured rooms on the home page (src/app/page.tsx, including their "from" prices);
  • the offer cards on /offers (keep them in sync with PROMOS);
  • reviews on the home page and the FAQ on /contact;
  • the phone number and email in SiteFooter.tsx.

hotel.ts also exports TESTIMONIALS and FAQS, which the pages don't currently read; you can render them from the data or delete them.

Images and artwork#

Artwork is 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, gallery, booking
pool.svg, spa.svg, boat.svg, dining.svg, breakfast.svg, bar.svg, events.svg 16:10 Feature cards and pages
map.svg 3:2 (1200 × 800) Location page
og-image.png 1200 × 630 Social image used by pageMetadata()

src/app/icon.svg is the favicon and src/app/opengraph-image.png the default social image (Next.js file conventions).

Always reference files in public/ through asset(), so they keep working when the site is served from a sub-folder:

tsx
<img src={asset("/img/room-loft.svg")} alt="Garden Loft: bedroom" width={1600} height={1000} />

To use photos, add .jpg/.webp files at the same aspect ratio and update the paths (the room gallery builds them in RoomView.tsx from the room id). 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.

  • Main menu links are in src/data/nav.ts (NAV). SiteHeader.tsx renders them in the desktop and mobile menus and marks the current route with aria-current="page" using usePathname().
  • To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata(title, description), and add a NAV entry if it belongs in the menu.
  • The footer (SiteFooter.tsx) has its own link columns, contact details and the newsletter form.

Features#

Booking bar#

BookingBar.tsx on the home page has arrival and departure dates, adults and children. It starts from the saved draft (or two weeks from today for three nights), keeps departure after arrival, validates with checkDates() (arrival not in the past, departure after arrival, at most MAX_NIGHTS nights) and then saves the stay and navigates to /booking?checkin=…&checkout=…&adults=…&children=… with router.push().

Room filters#

RoomsBrowser.tsx renders every room in ROOMS with three selects (view, beds, guests). Filtering happens in React; the current filters are written to the URL with history.replaceState, so /rooms?view=sea&guests=3 can be shared. A live count (announced with aria-live) and an empty state are shown.

Room page#

RoomView.tsx reads ?room= with useSearchParams() (inside a <Suspense> boundary in src/app/room/page.tsx, as a static export requires). An unknown id falls back to the first room, and the page title is set to the room's name in the browser because the static page can't know it in advance.

It shows the gallery, details, AMENITIES, the price form, the availability calendar and up to three other rooms.

Photo lightbox#

The gallery opens a native <dialog> with the bedroom, terrace and bathroom images. Arrow keys and the Previous/Next buttons move between photos; Esc or a backdrop click closes it, and focus returns to the page.

Prices#

All pricing lives in src/lib/booking.ts:

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.1 VAT included in prices; shown as "Includes VAT"
CITY_TAX 3.5 Per adult per night for the first 7 nights; shown separately, paid at the hotel
MAX_NIGHTS 21 Longest stay bookable online

quote(stay) returns a Quote (roomTotal, weekendNights, extraGuests, extras, discount, total, vat, cityTax):

  1. each night costs the room rate, plus WEEKEND_UPLIFT on Fridays and Saturdays (rounded to whole euros);
  2. extra guests are charged per night;
  3. breakfast is charged per guest per night, other extras once per stay;
  4. a valid promo takes its percentage off the room total only;
  5. VAT is the included share of the total; city tax is shown separately.

Breakdown.tsx renders a quote as a list. Change the currency in money().

Availability calendar#

The room page shows two months; booked nights are struck through. Click an arrival date, then a departure date. A stay that crosses a booked night is refused with a message in a polite live region.

Availability comes from isBlocked(roomId, iso) in src/lib/booking.ts, which returns a deterministic demo pattern. Replace it with data from your reservations system, for example a set of booked nights fetched when the page loads:

ts
const BOOKED = new Set<string>(); // fill from your API: `${roomId}:${isoDate}`
export function isBlocked(roomId: string, iso: string) {
  return BOOKED.has(`${roomId}:${iso}`);
}

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

Booking flow#

BookingFlow.tsx (rendered by src/app/booking/page.tsx inside <Suspense>) holds the stay and the guest details in React state:

  1. Dates and guests — validated with checkDates(). If the guests change and the chosen room no longer sleeps them, fitRoomToGuests() (in src/lib/booking.ts) switches to the first room that does (the same rule as for guests from the URL) 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 (5 or 6 guests), the summary shows no price, only "No room sleeps 6 guests" and a note to choose fewer guests or contact the hotel, and the room step lists every room as too small.
  2. Room — rooms with their price for the dates; rooms that are too small or booked are disabled with the reason.
  3. Extras — from EXTRAS.
  4. Guest details — name, email and phone (validated), arrival time and requests.
  5. Review — summary, promo code and the cancellation policy checkbox.

A sticky summary with the full breakdown sits beside the form. The current step is marked with aria-current="step" and focus moves to each step's heading. Values in the URL (/booking?room=sea&checkin=…&promo=LONGSTAY) override the draft, and arriving with a room starts on step 2.

On the final submit it shows a confirmation with a demo reference (CM-K3X9QZ) and clears the draft. Nothing is booked. The place to connect your booking engine is marked in onSubmit():

tsx
// Demo only: send `stay` and `guest` to your reservations system here.
setDone({ ref: bookingRef() });

Because the site is a static export there's no server code; call your booking engine's API from the browser, or remove the static export (see Building and deploying) and add a Route Handler. Always re-check price and availability on the server.

Promo codes#

Each Promo in PROMOS has code, name, percent (off the room total), minNights, advanceDays (minimum days before arrival, 0 for none), rooms (room ids, [] for all) and summary/terms text. checkPromo(code, stay) returns a message that names the rule that failed. Codes can come from the URL (/booking?promo=LONGSTAY).

Dining menu tabs#

MenuTabs.tsx renders one tab per key of MENUS with the ARIA tabs pattern: arrow keys move between tabs, Home and End jump to the ends, and only the selected tab is in the tab order. Add a key to MENUS to add a menu.

Experiences filter#

ExperiencesGrid.tsx filters EXPERIENCES by type with toggle buttons (aria-pressed). A matching hash selects a category on load, for example /experiences#sea.

Copy offer codes and toasts#

CopyCodeButton.tsx copies a code to the clipboard (HTTPS or localhost only; otherwise it shows the code) and calls showToast() from Toast.tsx. showToast(message) works from any client component: it dispatches a stay:toast event that the <Toast /> in the layout displays in a polite live region.

Contact prefill#

ContactPrefill.tsx reads /contact?topic=events&about=boat, selects the topic and starts the message. The about keys and their wording are in its NAMES object.

Header, mobile menu and scroll reveals#

SiteHeader.tsx adds a border and shadow after scrolling, and its menu button toggles the mobile menu (aria-expanded, Esc closes it and returns focus, and it closes on navigation). RevealObserver.tsx fades in .reveal elements as they scroll into view, revealing everything above too; with reduced motion, or before JavaScript runs (the js class is set 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 elements marked data-back-to-top-hide (the sticky booking summary) or while the mobile menu is open. Remove <BackToTop /> from the layout to drop it.

Forms#

Form Where Validates
Booking bar BookingBar.tsx (home) Dates
Room price RoomView.tsx Dates, capacity, availability
Booking BookingFlow.tsx Each step
Table request (data-form="table") src/app/dining/page.tsx Required fields, email, date from today
Contact (data-form="contact") src/app/contact/page.tsx Name, email, message of at least 10 characters
Newsletter (data-form="newsletter") SiteFooter.tsx Email

The table, contact and newsletter forms use ValidatedForm.tsx: native constraints (required, type="email", minLength, a min date set by data-min-today) are checked on blur and submit, errors appear in the element named by each field's aria-describedby, focus moves to the first error, and the element marked data-form-success is shown. Nothing is sent until you connect it.

To post to a form service such as Formspree, replace the comment in onSubmit():

tsx
// To send for real: fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form) })
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(form),
  headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here

(make onSubmit async), then keep the existing reset and success code. Pass a different endpoint per form with a prop if needed, and update the success messages, which currently say the form is a demo.

If you remove the static export, you can post to your own Route Handler instead (for example src/app/api/contact/route.ts exporting POST), or turn the form into a Server Action.

Saved data (localStorage)#

Key Shape
casa-marea:booking:v1 (DRAFT_KEY) Partial<Stay>: { checkin, checkout, adults, children, room, extras, promo }

The booking bar, room page and booking flow share this draft through readDraft(), saveDraft() and clearDraft() in src/lib/booking.ts. Every read goes through sanitizeDraft(): real YYYY-MM-DD dates only, adults 1–4 and children 0–2 (whole numbers), known room and extra ids (no duplicates), and a promo of 3–20 letters or digits. Invalid JSON or values are dropped and the stored copy is repaired. initialStay() merges the draft over the defaults and replaces past dates; in the booking flow fitGuestsToRoom() then checks the guests against the room (guests from the URL 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; reset it by hand with localStorage.removeItem("casa-marea:booking:v1").

SEO and sharing#

  • Site-wide defaults (title, description, Open Graph) are in metadata in src/app/layout.tsx.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which also sets the Open Graph image asset("/img/og-image.png").
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to https://example.com). Set it when you build so social images get absolute URLs:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-hotel.com pnpm build

For launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and Hotel structured data as JSON-LD in the home page.

Accessibility#

Built in:

  • a "Skip to content" link (in layout.tsx) and landmarks on every page;
  • visible focus styles (:focus-visible in globals.css) and one h1 per page;
  • form errors linked with aria-describedby/aria-invalid, with focus moved to the first error;
  • ARIA tabs, a native <dialog> lightbox, aria-current for steps and navigation, aria-pressed filter toggles;
  • live regions for filter counts, calendar messages and toasts;
  • reduced motion respected by reveals, smooth scrolling, the hero animation and the back-to-top button;
  • WCAG AA colour contrast.

When editing, keep one h1 per page, label every input, and keep alt text meaningful.

Building and deploying#

pnpm build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:

  • Vercel: import the repository; no settings needed.
  • Netlify / Cloudflare Pages: build command pnpm build, publish directory out.
  • Any static host: upload the contents of out/.

To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/hotel 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 and writes demo/, which you can delete.

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

  • useSearchParams() should be wrapped in a suspense boundary during build. Any component that reads the query string must render inside <Suspense>, as /room and /booking do.
  • Images or links break when deployed to a sub-folder. Build with DEMO_BASE_PATH set, and reference public files with asset() (links made with next/link get the base path automatically).
  • Social previews use example.com. Set NEXT_PUBLIC_SITE_URL when building.
  • The room page title shows the default hotel title in "view source". It's set in the browser by RoomView.tsx; for per-room titles in the HTML, switch to a dynamic route /rooms/[id] with generateStaticParams().
  • Home page prices don't match the booking total. The featured cards in src/app/page.tsx are written by hand; update them when you change rate.
  • pnpm lint crashes with contextOrFilename.getFilename is not a function. Keep settings.react.version in eslint.config.mjs.
  • The booking flow starts with old values. Clear the saved draft (see Saved data).

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 artwork. Casa Marea, its staff and its guests are fictional.

Search documentation

Search the guides and every template's documentation