Freeframe

Documentation

Bistro

v1.0.1Updated Oct 14, 2026A warm, high-end website for a restaurant, café or wine bar.

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).

sh
pnpm install
pnpm dev        # http://localhost:3000

pnpm 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.md

Components 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:

css
@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:

tsx
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):

ts
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:

ts
{ 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:

ts
{ 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.

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.

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_STEP minutes from opening until LAST_SEATING_BEFORE_CLOSE minutes before closing;
  • for today, only slots at least an hour from now ("No times left" when the day is over);
  • closed days (null in HOURS) show "Closed that day" and the date field explains why;
  • the date can't be in the past or more than BOOKING_MONTHS_AHEAD months ahead (the field's min and max are set from today in TIMEZONE);
  • 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:

tsx
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 metadata in src/app/layout.tsx (they are also the home page's title and description).
  • Every other page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which sets the title, description, Open Graph tags (with the image asset("/img/og-image.png")) and the Twitter card.
  • metadataBase comes from 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-restaurant.com pnpm build

Consider 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 h1 per page;
  • visible focus styles (:focus-visible in globals.css);
  • menu tabs with the ARIA tabs pattern and arrow keys; filter buttons with aria-pressed and a live result count;
  • diet badges with visible codes plus screen-reader text ("Vegetarian");
  • form errors linked with aria-describedby, aria-invalid on 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 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=/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 TIMEZONE in src/data/hours.ts to your restaurant's IANA zone name.
  • No reservation times appear. Check that HOURS for that weekday isn't null and that opening time to closing minus LAST_SEATING_BEFORE_CLOSE leaves at least one slot.
  • The highlighted "Today" row is wrong. The days of the rows in HOURS_TABLE must match the weekdays (0 = Sunday).
  • /menu#drinks doesn't open the Drinks tab. The hash must equal the menu's id in MENUS.
  • The Enquire button doesn't preselect a package. The package name must match an option of the "Space" select; SPACE_OPTIONS does this for you unless you edit the options by hand.
  • Images or links break under a sub-folder. Build with DEMO_BASE_PATH and reference public files with asset() (links made with next/link get the base path automatically).
  • Social previews point to example.com. Set NEXT_PUBLIC_SITE_URL when 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.

Search documentation

Search the guides and every template's documentation