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.
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 pathCustomising#
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 |
@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.
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:
{
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 withPROMOS); - 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:
<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.
Navigation, header and footer#
- Main menu links are in
src/data/nav.ts(NAV).SiteHeader.tsxrenders them in the desktop and mobile menus and marks the current route witharia-current="page"usingusePathname(). - To add a page, create
src/app/<route>/page.tsx, exportmetadata = pageMetadata(title, description), and add aNAVentry 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):
- each night costs the room
rate, plusWEEKEND_UPLIFTon Fridays and Saturdays (rounded to whole euros); - extra guests are charged per night;
- breakfast is charged per guest per night, other extras once per stay;
- a valid promo takes its percentage off the room total only;
- 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:
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:
- Dates and guests — validated with
checkDates(). If the guests change and the chosen room no longer sleeps them,fitRoomToGuests()(insrc/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. - Room — rooms with their price for the dates; rooms that are too small or booked are disabled with the reason.
- Extras — from
EXTRAS. - Guest details — name, email and phone (validated), arrival time and requests.
- 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():
// 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 |
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():
// 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
metadatainsrc/app/layout.tsx. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which also sets the Open Graph imageasset("/img/og-image.png"). metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tohttps://example.com). Set it when you build so social images get absolute URLs:
NEXT_PUBLIC_SITE_URL=https://www.your-hotel.com pnpm buildFor 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-visibleinglobals.css) and oneh1per page; - form errors linked with
aria-describedby/aria-invalid, with focus moved to the first error; - ARIA tabs, a native
<dialog>lightbox,aria-currentfor steps and navigation,aria-pressedfilter 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 directoryout. - Any static host: upload the contents of
out/.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/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 boundaryduring build. Any component that reads the query string must render inside<Suspense>, as/roomand/bookingdo.- Images or links break when deployed to a sub-folder. Build with
DEMO_BASE_PATHset, and reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews use
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. - 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]withgenerateStaticParams(). - Home page prices don't match the booking total. The featured cards in
src/app/page.tsxare written by hand; update them when you changerate. pnpm lintcrashes withcontextOrFilename.getFilename is not a function. Keepsettings.react.versionineslint.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.