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 Annapolis, Maryland, with tabbed menus and a diet filter, a reservation form whose time slots follow the opening hours, private dining packages with an inquiry form, and a live "Open now" badge. This version is built with Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, and is generated as a fully static site. This guide covers version 1.0.1.
Live demo: freeframe.dev/templates/bistro
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Open http://localhost:3000. Edits to app/ reload instantly.
| Script | What it does |
|---|---|
pnpm dev |
Development server |
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/bistro into demo/ |
Pages#
| Route | File | Purpose and notable sections |
|---|---|---|
/ |
app/pages/index.vue |
Fire hero with the open-now strip, intro and figures, signature dishes, chef quote, reviews, opening hours and a booking call to action |
/menu |
app/pages/menu.vue |
Lunch / Dinner / Drinks / Desserts tabs, vegetarian / vegan / gluten-free filter, allergen notes (#allergens) |
/about |
app/pages/about.vue |
Timeline, values, team (#team), suppliers |
/reservations |
app/pages/reservations.vue |
Reservation form with day-dependent time slots, opening hours and policies sidebar |
/events |
app/pages/events.vue |
Three private dining packages, upcoming events (#upcoming), inquiry form (#enquire) |
/contact |
app/pages/contact.vue |
Contact cards, opening hours with today highlighted, illustrated map, contact form, FAQ |
| 404 | app/error.vue |
Error page with links home, to the menu and to reservations (generated as 404.html) |
Every page has the newsletter form in the footer.
Project structure#
bistro/
├── app/
│ ├── app.vue <NuxtLayout><NuxtPage /></NuxtLayout>
│ ├── layouts/default.vue skip link, header, the page, footer, back-to-top button
│ ├── pages/ one .vue file per page (index, menu, about, reservations, events, contact)
│ ├── error.vue 404 page
│ ├── components/
│ │ ├── SiteHeader.vue fixed header and mobile menu
│ │ ├── SiteFooter.vue footer with the newsletter form and copyright year
│ │ ├── OpenStatus.vue "Open now" badge
│ │ ├── HoursTable.vue printed opening hours with today highlighted
│ │ ├── MenuBrowser.vue menu tabs and diet filter
│ │ ├── ValidatedForm.vue validation, success message and reset for every form
│ │ ├── FormField.vue labeled form field with its error message
│ │ ├── ReservationWhen.vue date, time slots and guests of the reservation form
│ │ ├── CountedTextarea.vue textarea with a character counter
│ │ ├── FormSuccessTick.vue check mark of the success messages
│ │ ├── PageHeader.vue dark header of the inner pages
│ │ ├── DietBadge.vue V / VG / GF badges
│ │ ├── TeamPortrait.vue illustrated team portraits
│ │ ├── AnnapolisMap.vue illustrated map on /contact
│ │ ├── LineIcon.vue, StarIcon.vue, SocialIcon.vue SVG icons
│ │ └── BackToTop.vue back-to-top button
│ ├── composables/
│ │ ├── useBusinessNow.ts shared "now" in the restaurant's time zone
│ │ ├── useFormContext.ts what a form shares with its fields
│ │ ├── usePageSeo.ts title, description, canonical and Open Graph tags
│ │ ├── useCurrentPage.ts aria-current for menu links
│ │ └── useAsset.ts prefixes public files with the base path
│ ├── 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
│ │ ├── reservations.ts seating choices and occasions
│ │ ├── events.ts packages, inquiry options, upcoming events
│ │ ├── contact.ts contact topics and FAQ
│ │ └── icons.ts line icon shapes
│ ├── utils/
│ │ ├── hours.ts businessNow(), openStatus(), timeSlots(), date helpers
│ │ └── forms.ts errorFor() validation messages, makeRef()
│ ├── plugins/
│ │ ├── reveal.client.ts scroll reveals
│ │ ├── keep-hash.client.ts keeps the URL hash exactly as opened (menu tabs)
│ │ └── stable-build.server.ts reproducible builds
│ ├── router.options.ts query strings written like the HTML version
│ └── assets/
│ ├── css/main.css Tailwind entry: fonts, brand tokens (@theme) and component classes
│ └── fonts/ Cormorant Garamond and Manrope (woff2) and OFL.txt
├── public/img/ SVG dishes, chef, fire, wine, favicon, og-image.svg and og-image.png
├── nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── eslint.config.mjs, tsconfig.json
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json template metadata used by freeframe.dev
└── README.md, CHANGELOG.md, DOCS.mdComponents, composables and utils are auto-imported by Nuxt; data files are imported by path
(import { MENUS } from "~/data/menu").
Customizing#
Brand and colors#
The palette is a set of tokens in the @theme block of app/assets/css/main.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 the theme-color meta tag in app.head in nuxt.config.ts (the browser bar color) and the
hard-coded colors of the illustrations if you want them to match.
Typography#
Headings use Cormorant Garamond (--font-display, weights 500 to 700 and italic 500 to 600) and text uses
Manrope (--font-sans, variable, 400 to 700). Both are self-hosted from app/assets/fonts/ with
@font-face rules at the top of main.css: the same Latin files Google Fonts serves, so text sets exactly like
the HTML version (licenses in app/assets/fonts/OFL.txt). The map on /contact names the same families in
its SVG font-family attributes.
To swap a font, put its .woff2 in app/assets/fonts/, change the @font-face rule and the family name in
--font-display or --font-sans:
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("../fonts/Lora-latin.woff2") format("woff2");
}Content#
Text that appears once lives in the page files (app/pages/*.vue); everything that repeats or that you are
likely to edit is in typed files in app/data/:
| File | What it holds |
|---|---|
site.ts |
SITE (name, address, phones, emails, gratuity note), 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 |
reservations.ts |
SEATING (the "Where would you like to sit?" cards), OCCASIONS |
events.ts |
PACKAGES, SPACE_OPTIONS, EVENT_TYPES, UPCOMING |
contact.ts |
TOPICS, FAQS |
Opening hours are set once in app/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 "America/Chicago"). 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 app/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: 14, 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 app/data/events.ts. Each package's name is also an option
of the inquiry form's "Space" select (SPACE_OPTIONS is built from them), so the card's Inquire 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
app/components/TeamPortrait.vue and the map in app/components/AnnapolisMap.vue. To use photos, put them
in public/img/ and change the paths in app/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 through useAsset(), without a leading slash, so they work under a base path:
<script setup lang="ts">
const asset = useAsset();
</script>
<template>
<img :src="asset('img/dish-lamb.svg')" alt="" width="800" height="800">
</template>The social image is public/img/og-image.png (1200×630, exported from og-image.svg); the favicon is
public/img/favicon.svg, linked in app.head in nuxt.config.ts.
Navigation, header and footer#
The header is app/components/SiteHeader.vue and the footer app/components/SiteFooter.vue; both are
rendered once in app/layouts/default.vue. Menu items come from NAV in app/data/site.ts (the mobile menu
adds Home first), and the footer's "Explore" links from FOOTER_LINKS. To add a page, create
app/pages/<route>.vue (with its root <main id="main"> and a usePageSeo() call), add the route to routes
in nuxt.config.ts, then add it to NAV or FOOTER_LINKS. The header marks the current page with
aria-current="page" (useCurrentPage()).
Features#
Open now badge and today's hours#
<OpenStatus /> 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 colored dot. <HoursTable /> adds the
is-today class to today's row. Both read "now" in TIMEZONE (businessNow() in app/utils/hours.ts)
through useBusinessNow(), so every visitor sees the restaurant's local time, refreshed every minute. The
generated HTML shows "See opening hours" until the page loads, because a static page can't know the current
time. Pass placeholder to change that text (the mobile menu uses "Opening hours").
Menu tabs#
app/components/MenuBrowser.vue 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. app/plugins/keep-hash.client.ts keeps
the hash exactly as it was opened, so only an exact match selects a tab.
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#
app/components/ReservationWhen.vue renders the date, time and guests fields of the reservation form. The
time options come from timeSlots() in app/utils/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 inquiry.
Character counter#
app/components/CountedTextarea.vue shows "12 / 300" under the special requests field, announced politely to
screen readers, and goes back to 0 when the form is reset.
Package inquiries#
The "Inquire" buttons on /events jump to #enquire and preselect their package in the "Space" select
(preselect() in app/pages/events.vue).
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 (app/plugins/reveal.client.ts, styles near
the end of main.css); stagger them with style="--d:100ms". Content is only hidden while JavaScript runs,
and reduced-motion settings show everything immediately. The plugin adds the is-visible class itself, so
don't put data-reveal on an element that also has a :class binding (Vue would drop the class on its next
update): wrap it instead.
Back to top button#
app/components/BackToTop.vue 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 app/layouts/default.vue and the
.back-to-top rules in main.css.
Forms#
All forms use <ValidatedForm> from app/components/ValidatedForm.vue:
| Form | Where | Fields |
|---|---|---|
Reservation (data-validate="reservation") |
app/pages/reservations.vue |
date, time, guests, seating (radio cards), name, email, phone, occasion, notes, newsletter |
Private dining inquiry (enquiry) |
app/pages/events.vue |
name, email, package, type, date, guests (8–60), message (20+ characters) |
Contact (contact) |
app/pages/contact.vue |
name, email, topic, message (10+ characters) |
Newsletter (newsletter) |
app/components/SiteFooter.vue |
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 app/utils/forms.ts). Fields are checked when they lose focus (once
filled), again as you type once they are marked invalid, and all together on submit; errors appear in the
element with id <field id>-error, and the first invalid field is focused. <FormField> renders the label and
that error element, and gives its slot invalid for the control's aria-invalid:
<FormField id="res-name" v-slot="{ invalid }" label="Full name" required>
<input id="res-name" name="name" type="text" class="input" required aria-describedby="res-name-error" :aria-invalid="invalid" data-msg-required="Please tell us your name.">
</FormField>On success the form is replaced by its #success slot, which gets fill, count and reset:
fill("name") repeats a submitted value (first name only; dates written out), count("guests", "guest", "guests") agrees with the number, and a button calling reset brings the form back, empty. fill("ref") shows a
short reference made in the browser (OE- and four characters, from makeRef() in app/utils/forms.ts). If your
booking system or form service returns its own reference, show that instead. The success messages say what
happens next ("We reply within a day", "within two business days", "We'll email to confirm your table"); change
them to match how you actually handle requests.
Nothing is sent until you connect the forms. In onSubmit() in app/components/ValidatedForm.vue, post the
data before showing the success message, for example to Formspree:
async function onSubmit() {
// ...validation as before...
const body = new FormData(form.value!);
const res = await fetch("https://formspree.io/f/YOUR_ID", {
method: "POST",
body,
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your request could not be sent. Please call us.");
const data: Record<string, string> = {};
body.forEach((value, key) => (data[key] = String(value)));
data.ref = makeRef("OE"); // or the reference your service returns
values.value = data;
await nextTick();
success.value?.querySelector<HTMLElement>('[tabindex="-1"]')?.focus();
}Use the kind prop to send each form to a different endpoint, and check that the success messages still describe
what happens next. With pnpm build (a Node.js server, see Building and deploying) you
can post to your own server route instead, for example server/api/reservations.post.ts. 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#
-
Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, a canonical link, Open Graph tags (withimg/og-image.png) and a large Twitter card. The 404 page passesnoindex: true. -
Canonical and social URLs are absolute: set your domain when building (the default is
https://bistro.example.com):shSITE_URL=https://www.your-restaurant.example pnpm generate -
Add a
sitemap.xmlandrobots.txttopublic/(or use a sitemap module) before launch. -
Consider Restaurant structured data as JSON-LD with your address, phone, hours and menu URL on the home and contact pages, added with
useHead({ script: [...] }).
Accessibility#
Built in:
- a "Skip to content" link, labeled landmarks and one
h1per page; - visible focus styles (
:focus-visibleinmain.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 labeled 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 <FormField>), and keep the screen-reader text of the
badges and meaningful alt text on images.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (menu.html,
linked as /menu), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json,
which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare
Pages, GitHub Pages, S3, Vercel). Hosts that don't map /menu to menu.html on their own need that rewrite
(Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/bistro pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load.
Troubleshooting#
| Problem | Fix |
|---|---|
| The open/closed badge is an hour or more out | Set TIMEZONE in app/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 Inquire 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 are missing when the site lives in a sub-folder | Build with DEMO_BASE_PATH=/your-folder and pass image paths through useAsset(), without a leading slash. |
| Social previews show no image | Set SITE_URL to your live domain when building, so og:image is an absolute URL. |
| A new page is missing from the generated site | Add its route to routes in nuxt.config.ts. |
pnpm lint can't find .nuxt/eslint.config.mjs |
Run pnpm install or npx nuxt prepare first. |
| Forms "send" but nothing arrives | Connect them as described in Forms. |
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Cormorant
Garamond and Manrope are licensed under the SIL Open Font License (app/assets/fonts/OFL.txt). Olive & Ember,
its team and its guests are fictional.