Small hotels rarely need a booking engine. What they need is a clean way for a guest to say "these dates, this many people, this room", and for the front desk to answer with an offer. While building Meridian, a hotel website template, we wanted exactly that on a site that is fully static (output: 'export'), with no API routes and no third-party form service.
Static matters here. A hotel website often lives on cheap shared hosting or a free Pages plan, gets touched once a year, and should still work when nobody remembers how it was deployed. Every moving part we add is one more thing that can quietly stop sending requests.
Here is how the booking request works and what we would change for a bigger hotel.
1. Rooms know how many guests they take
All content lives in one TypeScript file. Each room carries its capacity, and null means "on request" (the penthouse):
export type Room = { slug: string; name: string; /** Max guests; null = on request */ guests: number | null; short: string; text: string; facts: string[]; photo: { src: string; alt: string }; }; 2. Filter the choices, not the guest
The form keeps a guest counter between 1 and 8, and the room list is filtered on every change:
const [guests, setGuests] = useState(2); const fits = useMemo( () => rooms.filter((r) => r.guests === null || r.guests >= guests), [guests] ); Choose four guests and the single room, the double and the suite disappear; the family room and the penthouse stay. An "Any room that fits" option is always first, so nobody is forced to pick.
The rooms are radio inputs hidden with sr-only inside styled labels. Keyboard and screen reader users get a normal radio group, sighted users see pill buttons:
<fieldset> <legend>Room</legend> {[{ slug: '', name: 'Any room that fits' }, ...fits].map((r) => { const value = r.slug ? r.name : ''; return ( <label key={r.slug} className={room === value ? 'bg-pine text-cream' : 'bg-stone-3'}> <input type="radio" name="room" className="sr-only" checked={room === value} onChange={() => setRoom(value)} /> {r.name} </label> ); })} </fieldset> 3. Dates that can't go wrong
<input type="date"> gives ISO strings like 2026-11-14. Two small rules keep them sane:
- the arrival
minis today, the departureminis the arrival date; - picking an arrival on or after the current departure moves the departure to the next day.
const today = () => new Date().toISOString().slice(0, 10); const addDays = (d: string, n: number) => { const x = new Date(d); x.setDate(x.getDate() + n); return x.toISOString().slice(0, 10); }; <input type="date" min={today()} value={arrive} onChange={(e) => { setArrive(e.target.value); if (!depart || depart <= e.target.value) setDepart(addDays(e.target.value, 1)); }} /> Date-only ISO strings are parsed as UTC midnight, so the number of nights is a clean division, and plain string comparison (depart <= arrive) works without creating Date objects:
const nights = arrive && depart ? Math.round((+new Date(depart) - +new Date(arrive)) / 864e5) : 0; 4. Submit without a server
On submit we validate, build a plain-text summary and hand it to the visitor's mail app:
const body = [ `Arrival: ${arrive}`, `Departure: ${depart}`, `${nights} nights`, `Guests: ${guests}`, `Room: ${room || 'Any room that fits'}`, `Name: ${f.get('name')}`, `Contact: ${f.get('contact')}`, ].join('\n'); window.location.href = `mailto:${email}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; Errors appear in an element with role="alert", the guest count is an <output aria-live="polite">, and after sending, the form is replaced by a role="status" message.
5. Trade-offs
mailto: is honest about what it is: the request leaves from the guest's own address and lands in the hotel inbox. There is nothing to host or pay for, and nothing breaks when the site moves to another server. The cost is that it relies on a mail app being set up on the visitor's device. If that matters, replace the last line with a fetch() to Formspree, a Cloudflare Worker or your own endpoint; the summary string stays exactly the same.
What it does not do is check availability. For a small hotel whose front desk answers quickly, that's fine. For a bigger one, this form becomes the front end of a real booking API. If we were building it for a large property, the next steps would be:
- a
fetch()to an endpoint that stores the request and sends the guest a confirmation; - an availability check per room type, so fully booked rooms drop out of
fitsthe same way small rooms do now; - a honeypot field or Cloudflare Turnstile, because a public endpoint attracts spam in a way
mailto:never does.
None of that changes the form itself, which is the nice part of keeping the summary as plain text.
The template
Meridian also has room tabs, wellness, dining and meeting pages, and the hotel name set as a full-width headline on the hero. It's a static Next.js App Router site with Tailwind CSS and Motion, and all texts live in one file.
Demo and download: https://market.designzonic.com/product/hotel-meridian

