Overview#
Bistro is a warm, high-end website for a restaurant, café or wine bar. The demo restaurant is Olive & Ember, a fictional wood-fired Mediterranean kitchen in an old town, with tabbed menus and a diet filter, a reservation form whose time slots follow the opening hours, private dining packages with an enquiry form, and a live "Open now" badge. This version is built with Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, and is exported as a fully static site. This guide covers version 1.0.0.
Live demo: freeframe.dev/templates/bistro
Quick start#
Requires Node.js 24 and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000pnpm build writes the static site to out/, and pnpm start serves that folder.
Pages#
| Route | File | Purpose and notable sections |
|---|---|---|
/ |
src/app/page.tsx |
Fire hero with the open-now strip, intro and figures, signature dishes, chef quote, reviews, opening hours and a booking call to action |
/menu |
src/app/menu/page.tsx |
Lunch / Dinner / Drinks / Desserts tabs, vegetarian / vegan / gluten-free filter, allergen notes (#allergens) |
/about |
src/app/about/page.tsx |
Timeline, values, team (#team), suppliers |
/reservations |
src/app/reservations/page.tsx |
Reservation form with day-dependent time slots, opening hours and policies sidebar |
/events |
src/app/events/page.tsx |
Three private dining packages, upcoming events (#upcoming), enquiry form (#enquire) |
/contact |
src/app/contact/page.tsx |
Contact cards, opening hours with today highlighted, illustrated map, contact form, FAQ |
| 404 | src/app/not-found.tsx |
Error page with links home, to the menu and to reservations |
Every page has the newsletter form in the footer.
Project structure#
bistro/
├── src/
│ ├── app/
│ │ ├── layout.tsx fonts, site metadata, header, footer, reveals, back-to-top
│ │ ├── globals.css Tailwind entry: brand tokens (@theme) and component classes
│ │ ├── page.tsx home page
│ │ ├── menu/ about/ reservations/ events/ contact/ one page.tsx per route
│ │ ├── not-found.tsx 404 page
│ │ ├── icon.svg favicon
│ ├── components/
│ │ ├── site-header.tsx fixed header and mobile menu (client)
│ │ ├── site-footer.tsx footer with newsletter form
│ │ ├── open-status.tsx "Open now" badge (client)
│ │ ├── hours-table.tsx printed opening hours with today highlighted (client)
│ │ ├── menu-browser.tsx menu tabs and diet filter (client)
│ │ ├── forms.tsx ValidatedForm, Fill, CountOf, ResetButton, ReservationWhen... (client)
│ │ ├── field.tsx labelled form field and the demo notice
│ │ ├── enquire-link.tsx package "Enquire" links (client)
│ │ ├── page-header.tsx dark header of the inner pages
│ │ ├── diet-badge.tsx V / VG / GF badges
│ │ ├── portraits.tsx illustrated team portraits
│ │ ├── old-town-map.tsx illustrated map on /contact
│ │ ├── icons.tsx SVG icons
│ │ ├── reveal-observer.tsx scroll reveals (client)
│ │ ├── back-to-top.tsx back-to-top button (client)
│ │ └── year.tsx copyright year (client)
│ ├── data/
│ │ ├── site.ts name, address, phone, email, navigation, footer links, socials
│ │ ├── hours.ts opening hours, time zone, slot settings, printed hours table
│ │ ├── menu.ts the four menus and the diet codes
│ │ ├── home.ts signature dishes, figures, reviews
│ │ ├── about.ts timeline, values, team, suppliers
│ │ ├── events.ts packages, enquiry options, upcoming events
│ │ └── contact.ts contact topics and FAQ
│ ├── lib/
│ │ ├── hours.ts businessNow(), openStatus(), timeSlots(), date helpers
│ │ ├── use-business-now.ts shared "now" for the client components
│ │ ├── forms.ts validation messages (errorFor, showError, clearError)
│ │ ├── metadata.ts pageMetadata() for titles, descriptions and Open Graph
│ │ ├── asset.ts asset(): prefixes public files with the base path
│ │ └── style.ts delay(): reveal delays
│ └── fonts/ Cormorant Garamond and Manrope (woff2) and OFL.txt
├── public/img/ SVG dishes, chef, fire, wine, favicon, og-image.svg and og-image.png
├── next.config.ts static export and base path
├── eslint.config.mjs, tsconfig.json, postcss.config.mjs
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json template metadata used by freeframe.dev
└── README.md, CHANGELOG.md, DOCS.mdComponents are Server Components unless marked "(client)": only the interactive pieces run in the browser.
Customising#
Brand and colours#
The palette is a set of tokens in the @theme block of src/app/globals.css:
| Token | Used for |
|---|---|
--color-char, --color-char-2, --color-char-3 |
Dark charcoal backgrounds (header, page headers, dark bands, footer) |
--color-cream, --color-parchment, --color-sand |
Light backgrounds and borders |
--color-ember, --color-ember-dark, --color-ember-light |
Primary accent: buttons, links, prices |
--color-olive, --color-olive-light |
Secondary accent: menu group titles, vegan badges |
--color-gold |
Highlights, "Today" tags, focus outlines |
--color-muted |
Secondary text |
For example, a deep blue accent instead of ember:
@theme {
--color-ember: #2b4c7e;
--color-ember-dark: #1f3a63;
--color-ember-light: #7c9ccf;
}Also update themeColor in viewport in src/app/layout.tsx (the browser bar colour) and the hard-coded
colours of the illustrations if you want them to match.
Typography#
Headings use Cormorant Garamond (--font-display) and text uses Manrope (--font-sans). The font
files are self-hosted in src/fonts/ and loaded with next/font/local in src/app/layout.tsx, which sets
the --font-cormorant and --font-manrope variables used by the tokens. To use other fonts, either drop
new .woff2 files into src/fonts/ and change the localFont() calls, or switch to next/font/google:
import { Lora } from "next/font/google";
const cormorant = Lora({ variable: "--font-cormorant", subsets: ["latin"] });Content#
Text that appears once lives in the page files (src/app/**/page.tsx); everything that repeats or that
you are likely to edit is in typed files in src/data/:
| File | What it holds |
|---|---|
site.ts |
SITE (name, address, phones, emails, service charge), NAV, FOOTER_LINKS, SOCIAL |
hours.ts |
HOURS, TIMEZONE, LAST_SEATING_BEFORE_CLOSE, SLOT_STEP, BOOKING_MONTHS_AHEAD, HOURS_TABLE |
menu.ts |
MENUS (tabs, groups and dishes) and DIETS (badge codes) |
home.ts |
SIGNATURE_DISHES, FACTS, REVIEWS |
about.ts |
TIMELINE, VALUES, TEAM, SUPPLIERS |
events.ts |
PACKAGES, SPACE_OPTIONS, EVENT_TYPES, UPCOMING |
contact.ts |
TOPICS, FAQS |
Opening hours are set once in src/data/hours.ts, in minutes after midnight per weekday (0 = Sunday):
export const HOURS: Record<number, DayHours | null> = {
0: { open: 12 * 60, close: 16 * 60 },
1: null, // closed on Mondays
2: { open: 17 * 60, close: 22 * 60 },
// ...
};
export const TIMEZONE = "America/New_York";Set TIMEZONE to your restaurant's IANA zone (for example "Europe/London"). HOURS drives the "Open
now" badges, the highlighted row in the hours tables and the reservation time slots. The printed table is
HOURS_TABLE in the same file: keep it in step with HOURS. Each row lists the weekdays it covers so
today's row can be highlighted:
{ label: "Tuesday – Thursday", days: [2, 3, 4], hours: "5pm – 10pm" },Menu dishes are in MENUS in src/data/menu.ts. Each menu is a tab (id is also its URL hash) with
groups of dishes. To add a dish, add it to a group:
{ name: "Grilled leeks", description: "Romesco, toasted hazelnuts", price: 11, diet: ["vg", "gf"] },diet lists the codes "v" (vegetarian), "vg" (vegan) and "gf" (gluten-free); the badges and the
filter follow automatically. A new menu (tab) is a new entry in MENUS.
Private dining packages are PACKAGES in src/data/events.ts. Each package's name is also an option
of the enquiry form's "Space" select (SPACE_OPTIONS is built from them), so the card's Enquire button can
preselect it. Upcoming events (UPCOMING) show their dates as written: update them as events pass.
Images and artwork#
All artwork in public/img/ is original SVG: dishes (dish-*.svg), chef.svg, hero-fire.svg,
wine.svg and favicon.svg (also the logo in the header and footer). The team portraits are drawn in
src/components/portraits.tsx and the map in src/components/old-town-map.tsx. To use photos, put them in
public/img/ and change the paths in src/data/*.ts or the pages; keep similar proportions (dish cards are
4:5 and square, package images 16:10, the hero is wide) and write descriptive alt text. Reference public
files with asset("/img/...") so they work under a base path.
The social image is public/img/og-image.png (1200×630, exported from og-image.svg); the favicon is
src/app/icon.svg.
Navigation, header and footer#
The header is src/components/site-header.tsx and the footer src/components/site-footer.tsx; both are
rendered once in src/app/layout.tsx. Menu items come from NAV in src/data/site.ts (the mobile menu adds
Home first), and the footer's "Explore" links from FOOTER_LINKS. To add a page, create
src/app/<route>/page.tsx (export metadata = pageMetadata(...)), then add it to NAV or FOOTER_LINKS.
The current page is marked with aria-current="page" automatically.
Features#
Open now badge and today's hours#
<OpenStatus /> (src/components/open-status.tsx) shows the live status from HOURS, for example "Open
now · until 10pm" or "Closed · opens tomorrow at 5pm", and sets data-state="open" or "closed" for the
coloured dot. <HoursTable /> adds the is-today class to today's row. Both read "now" in TIMEZONE
(businessNow() in src/lib/hours.ts), so every visitor sees the restaurant's local time, and refresh
every minute through useBusinessNow(). The prerendered HTML shows "See opening hours" until the page loads,
because a static page can't know the current time.
Menu tabs#
src/components/menu-browser.tsx renders MENUS with the WAI-ARIA tabs pattern: role="tab" buttons with
aria-selected and aria-controls, a roving tabIndex, Left/Right arrows (wrapping) and Home/End.
Clicking a tab updates the URL hash, and /menu#drinks opens the Drinks tab (any other hash opens the first
tab). Links such as "See the full menu" on the home page use this.
Diet filter#
The filter buttons (data-diet-filter: all, v, vg, gf, with aria-pressed) show only matching
dishes in every tab; vegan dishes also count as vegetarian. Groups with no matches are hidden, a message
appears if a whole tab is empty, and a polite live region announces the result ("Showing 33 matching dishes
and drinks.").
Reservation time slots#
ReservationWhen in src/components/forms.tsx renders the date, time and guests fields of the reservation
form. The time options come from timeSlots() in src/lib/hours.ts:
- slots every
SLOT_STEPminutes from opening untilLAST_SEATING_BEFORE_CLOSEminutes before closing; - for today, only slots at least an hour from now ("No times left" when the day is over);
- closed days (
nullinHOURS) show "Closed that day" and the date field explains why; - the date can't be in the past or more than
BOOKING_MONTHS_AHEADmonths ahead (the field'sminandmaxare set from today inTIMEZONE); - choosing a new date starts the time list again, and a note shows that day's kitchen hours and last seating;
- "9 or more" guests shows a note pointing to the private dining enquiry.
Character counter#
CountedTextarea (in forms.tsx) shows "12 / 300" under the special requests field, announced politely to
screen readers, and goes back to 0 when the form is reset.
Package enquiries#
The "Enquire" buttons on /events (src/components/enquire-link.tsx) jump to #enquire and preselect
their package in the "Space" select.
Header and mobile menu#
The fixed header (data-header) gets a dark background once you scroll or open the menu. Below desktop width
the button with data-nav-toggle opens #mobile-nav, moves focus to the first link, locks page scrolling,
and closes with Esc (returning focus to the button), when a link is chosen, or when the window gets wider.
Scroll reveals#
Elements with data-reveal fade in as they enter the viewport (src/components/reveal-observer.tsx, styles
at the end of globals.css); stagger them with style={delay(100)} from src/lib/style.ts. Content is only
hidden while JavaScript runs, and reduced-motion settings show everything immediately.
Back to top button#
src/components/back-to-top.tsx adds a button in the bottom-right corner that appears after about one screen
of scrolling, scrolls to the top (instantly with reduced motion) and moves focus to the main content. Add
data-back-to-top-avoid to anything you pin to the bottom of the screen, or data-back-to-top-hide to a
sticky element it must never cover. To remove it, delete <BackToTop /> from layout.tsx and the
.back-to-top rules in globals.css.
Forms#
All forms use ValidatedForm from src/components/forms.tsx:
| Form | Where | Fields |
|---|---|---|
Reservation (data-validate="reservation") |
src/app/reservations/page.tsx |
date, time, guests, seating (radio cards), name, email, phone, occasion, notes, newsletter |
Private dining enquiry (enquiry) |
src/app/events/page.tsx |
name, email, package, type, date, guests (8–60), message (20+ characters) |
Contact (contact) |
src/app/contact/page.tsx |
name, email, topic, message (10+ characters) |
Newsletter (newsletter) |
src/components/site-footer.tsx |
email |
Validation uses the browser's constraints (required, type="email", pattern, min/max, minLength)
with messages from data attributes on each field: data-msg-required, data-msg-pattern, data-msg-min
and data-msg-max (see errorFor() in src/lib/forms.ts). Fields are checked when they lose focus (once
filled) and all together on submit; errors appear in the element with id <field id>-error (the Field
component in src/components/field.tsx renders it), and the first invalid field is focused. On success the
form is replaced by its success content, where <Fill name="name" /> repeats a submitted value (first name
only; dates written out) and <CountOf name="guests" one="guest" other="guests" /> agrees with the number.
<ResetButton> brings the form back, empty.
Nothing is sent until you connect the forms. In onSubmit in src/components/forms.tsx, post the data
before showing the success message, for example to Formspree:
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
// ...validation as before...
const data = new FormData(form.current!);
const res = await fetch("https://formspree.io/f/YOUR_ID", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your request could not be sent. Please call us.");
flushSync(() => setValues(data));
successRef.current?.querySelector<HTMLElement>('[tabindex="-1"]')?.focus();
}Use the kind prop to send each form to a different endpoint, and update the success messages (they say the
form is a demo; see DemoNotice in field.tsx). If you remove the static export (see
Building and deploying), you can post to your own Route Handler (for example
src/app/api/reservations/route.ts exporting POST) or call a Server Action instead. For real
reservations you will usually want a booking system (OpenTable, Resy, SevenRooms...): replace the reservation
form with their widget or link and keep the page around it.
SEO and sharing#
- Site-wide defaults are in
metadatainsrc/app/layout.tsx(they are also the home page's title and description). - Every other page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which sets the title, description, Open Graph tags (with the imageasset("/img/og-image.png")) and the Twitter card. metadataBasecomes fromNEXT_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-restaurant.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export) and
Restaurant structured data as JSON-LD with your address, phone, hours and
menu URL on the home and contact pages.
Accessibility#
Built in:
- a "Skip to content" link, labelled landmarks and one
h1per page; - visible focus styles (
:focus-visibleinglobals.css); - menu tabs with the ARIA tabs pattern and arrow keys; filter buttons with
aria-pressedand a live result count; - diet badges with visible codes plus screen-reader text ("Vegetarian");
- form errors linked with
aria-describedby,aria-invalidon fields, focus on the first error and on the success heading; - a mobile menu with
aria-expanded, Esc to close and focus management; - a labelled map illustration (
role="img"with a title and description); - reveals, the flickering glow and smooth scrolling respect
prefers-reduced-motion.
When editing, keep one h1 per page, label new inputs (use Field), and keep the screen-reader text of the
badges and meaningful alt text on images.
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=/bistro 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 images: { unoptimized: true } from next.config.ts and deploy to a Node-capable host such as Vercel.
Everything else keeps working.
pnpm lint runs ESLint 10 and pnpm typecheck runs TypeScript 7 (@typescript/native); the typescript
package is an alias of @typescript/typescript6, because typescript-eslint still needs the TypeScript 6 API.
Troubleshooting#
- The open/closed badge is an hour or more out. Set
TIMEZONEinsrc/data/hours.tsto your restaurant's IANA zone name. - No reservation times appear. Check that
HOURSfor that weekday isn'tnulland that opening time to closing minusLAST_SEATING_BEFORE_CLOSEleaves at least one slot. - The highlighted "Today" row is wrong. The
daysof the rows inHOURS_TABLEmust match the weekdays (0 = Sunday). /menu#drinksdoesn't open the Drinks tab. The hash must equal the menu'sidinMENUS.- The Enquire button doesn't preselect a package. The package
namemust match an option of the "Space" select;SPACE_OPTIONSdoes this for you unless you edit the options by hand. - Images or links break under a sub-folder. Build with
DEMO_BASE_PATHand reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews point to
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. - Forms "send" but nothing arrives. Connect them as described in Forms.
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. Cormorant Garamond and Manrope are licensed under the SIL Open Font
License (src/fonts/OFL.txt). Olive & Ember, its team and its guests are fictional.