Freeframe

Documentation

Wander

v1.0.2Updated Oct 17, 2026A sun-warmed, editorial site for a small-group tour company, with a trip finder, filters and a booking enquiry.

Overview#

Wander is a website for a small-group tour company, built for Saltwind Journeys, a fictional travel company on Bristol's harbourside that runs trips of twelve travellers or fewer in Morocco, Peru, Norway, the Aegean, Japan and Tanzania. It has a trip finder, a filterable tours page, tour pages with a day-by-day itinerary, a route map, departure dates and a live-priced booking enquiry, a destinations page with a seasonality chart, a journal with an article, an about page and a contact page. This is the Next.js version: Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. This guide covers version 1.0.0. See it live at freeframe.dev/templates/wander.

Quick start#

You need Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev

Open http://localhost:3000. Edits to files in src/ reload the page.

Script What it does
pnpm dev Development server
pnpm build Static export to out/
pnpm start Serve out/ locally
pnpm lint ESLint
pnpm typecheck TypeScript

Pages#

Route Purpose Notable sections and features
/ (src/app/page.tsx) Home Hero with postcard art, trip finder, destination ticker, featured tours (from the data), why travel with us, destinations grid, traveller postcards, journal teaser, newsletter
/tours All tours Region, style, duration, month and budget filters with live counts, sort, filter chips, result count, empty state, URL parameters
/tour One tour (/tour?id=atlas-sahara) Key facts, highlights, itinerary accordion, route map, included and excluded, departure dates table, booking widget, enquiry form, related tours, mobile price bar
/destinations Regions and when to go Seasonality table (regions × months), a section per region with highlights and a best-time chart
/journal Travel guides and stories Featured story, topic filter (/journal?topic=food), story cards
/article One journal article Reading progress bar, table of contents, drop cap, pull quote, packing checklist, related tours
/about Company story Numbers, timeline, guides with illustrated portraits, responsible travel commitments, "where your money goes" bar
/contact Contact & FAQ Contact form with trip prefill (/contact?tour=kumano-kodo), office details, emergency line, FAQ (/contact#faq)
404 (src/app/not-found.tsx) Not found Compass illustration and links back into the site

Project structure#

wander/
  src/app/
    layout.tsx               announcement bar, header, footer, toast, reveals, back to top
    fonts.ts                 self-hosted fonts (next/font/local)
    globals.css              Tailwind CSS: brand tokens (@theme) and component classes
    page.tsx                 home page; one folder per route (tours/, tour/, destinations/ ...)
    not-found.tsx            404 page
    icon.svg                 favicon
    opengraph-image.png      social sharing image (1200×630)
  src/components/
    SiteHeader.tsx           header, main menu and mobile menu
    SiteFooter.tsx           footer
    TripFinder.tsx           home page trip finder
    ToursBrowser.tsx         tours page filters, sort, chips and results
    TourCard.tsx             trip card used on every page
    TourDetail.tsx           tour page: itinerary, departures, booking widget, enquiry form
    Seasons.tsx              seasonality table and per-region charts
    JournalStories.tsx       journal stories and topic filter
    ContactForm.tsx          contact form with trip prefill
    NewsletterForm.tsx       home page sign-up
    useValidatedForm.ts      shared form validation and success state
    Landscape.tsx            illustrated landscapes (Art, LandscapeSvg)
    RouteMap.tsx, Avatar.tsx, Postmark.tsx, Logo.tsx, Icons.tsx   other SVG artwork
    ReadProgress.tsx, RevealObserver.tsx, BackToTop.tsx, Toast.tsx, Year.tsx
  src/fonts/                 woff2 font files and their license (OFL.txt)
  src/data/
    tours.ts                 regions, styles, durations, settings and the 12 tours
    content.ts               destinations copy, reviews, journal posts, guides, timeline, money split
    site.ts                  phone, email and the menu
  src/lib/
    tours.ts                 lookups, departures and prices, filters and URL state
    art.ts                   landscape colours (SKIES, LAYERS) and art types
    validate.ts              form field rules and messages
    metadata.ts, toast.ts, asset.ts
  next.config.ts             static export and base path
  template.json              gallery metadata (used by freeframe.dev)
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

The design tokens are in the @theme block of src/app/globals.css. Every utility such as bg-clay or text-muted comes from them.

Token Default Used for
--color-paper #fbf5ea Page background
--color-postcard #fffaf1 Cards, form fields, stamps
--color-sand / --color-dune #f4e6cc / #ecd5ad Panels, chart cells
--color-line #e2cfae Borders and dashed rules
--color-ink #23180f Text, outlines and the offset card shadows
--color-muted #66523f Secondary text
--color-clay / --color-clay-dark #b0431d / #8c3214 Brand colour: primary buttons, links, eyebrows
--color-sun / --color-sun-deep #f4a62a / #b86e00 Stamps, ticker, highlights / stars and icons
--color-sea / --color-deep #0e5a5f / #0a3b3f Dark bands, "best" season, footer
--color-lagoon, --color-coral #7cc4b8, #ef7b5a Decoration and charts
--color-foam #fff9ef Text on dark backgrounds

To change the brand colour, edit the tokens:

css
@theme {
  --color-clay: #2f6b4f;
  --color-clay-dark: #214d39;
}

A few colours are written directly into SVG: the landscape palettes (SKIES and LAYERS in src/lib/art.ts), the route map in src/components/RouteMap.tsx, the logo in src/components/Logo.tsx and src/app/icon.svg. Search for the hex value to change them too.

Typography#

Three Google Fonts, self-hosted with next/font/local in src/app/fonts.ts from the woff2 files in src/fonts/ (SIL Open Font License, see src/fonts/OFL.txt): Caprasimo for headings (font-display), Outfit for body text (font-sans) and Kalam for handwritten postcard notes (font-hand, the .hand class). Each family has a latin and a latin-ext file, so names such as Hongū render in the same font. Every face keeps its plain family name, which the tokens in globals.css use:

css
--font-sans: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: "Caprasimo", "Cooper Black", ui-serif, Georgia, serif;
--font-hand: "Kalam", "Segoe Print", "Bradley Hand", cursive;

To swap a font, put its woff2 files in src/fonts/, change the src paths and family names in fonts.ts, and change the names in the tokens. Caprasimo has a single weight, so headings use font-normal. The SVG text in the route map, postmark and 404 compass names Outfit or Kalam directly.

Content#

Trip data is in src/data/tours.ts; everything the tours page, tour pages, filters, booking widget and contact form show is built from it.

Export What it holds
REGIONS Regions: id, name, label, the scene and time of their artwork and a season array (12 values, 0 avoid to 3 best)
STYLES Trip styles (hiking, culture, wildlife, coast) and their labels
DURATIONS Duration filter buckets with min and max days
TOURS The trips, one object each (type Tour)
INCLUDED, EXCLUDED Lines shown on every tour page, after or before each tour's own includes / excludes
PRICE_MIN, PRICE_MAX Range of the budget slider on /tours
PEAK_SUPPLEMENT Added per person to departures in a region's best months
DEPOSIT Deposit per person shown in the booking widget
MAX_TRAVELLERS Most travellers in one enquiry
BOOKING_CUTOFF Departures sooner than this many days from today are not offered
DEPARTURE_DAYS Days of the month after which each departure's Saturday is chosen

To add a tour, add an object to TOURS:

ts
{
  id: "dolomites-huts", name: "Dolomites Hut to Hut", region: "aegean", country: "Italy", style: "hiking",
  days: 8, price: 2290, single: 280, rating: 4.8, reviews: 0, group: 12, level: "Moderate", badge: "New",
  months: [6, 7, 8, 9], scene: "mountains", time: "day",
  summary: "Seven days between mountain huts, with your bags sent ahead.",
  highlights: ["Tre Cime at sunrise", "Nights in family-run huts", "Via ferrata taster"],
  includes: ["Hut-to-hut luggage transfers"],
  excludes: [],
  stops: [[20, 70, "Cortina"], [45, 45, "Tre Cime"], [75, 30, "Sesto"]],
  itinerary: [
    { d: "1", t: "Cortina", x: "Meet the group and walk to the first hut.", stay: "Rifugio Lago" },
    { d: "2–7", t: "Hut to hut", x: "Six days on high trails.", stay: "Mountain huts" },
    { d: "8", t: "Departure", x: "Transfers to Venice airport.", stay: "" },
  ],
},
  • id is used in URLs (/tour?id=dolomites-huts, /contact?tour=dolomites-huts): lower case, no spaces.
  • region must be the id of a REGIONS entry and style the id of a STYLES entry. TypeScript checks both. The tour then appears under those filters, in the trip finder and in the contact form's trip list.
  • To add a region, add its id to the RegionId type and an entry to REGIONS; the filters, trip finder, seasonality table and home page grid pick it up. Add a matching entry to DESTINATIONS in src/data/content.ts for its section on /destinations (and the home grid's "best" months). The home page grid's tile sizes (TILE in src/app/page.tsx) are laid out for six regions.
  • price is the lowest price per person and single the own-room supplement per person, in pounds.
  • months (1–12) are the months the tour departs; they drive the month filter and the departure dates.
  • stops are route map points: [x, y, name], with x and y as percentages of the map's width and height.
  • itinerary entries are accordion rows: d is the day or range, t the title, x the text and stay the overnight (leave empty to hide it).
  • featured: 1 to 4 puts the tour on the home page in that order and first in "Recommended" sort; badge adds a label on the card.

Other content is in src/data/content.ts: DESTINATIONS (region copy, highlights and "best" months), REVIEWS, POSTS and TOPICS (journal), HOME_POSTS, GUIDES, TIMELINE and MONEY_SPLIT. Page copy that appears once is written in the page files in src/app/. The phone number, email and menu are in src/data/site.ts.

Images and artwork#

There are no photos. Every landscape is an SVG drawn by LandscapeSvg in src/components/Landscape.tsx with the colours in src/lib/art.ts. Scenes are dunes, mountains, fjord (with northern lights at night), coast, fuji and savannah; times are dawn, day, dusk and night. Art wraps one in a frame; pass label to give it alt text (without it the art is decorative):

tsx
<Art className="aspect-[4/3] rounded-[2rem]" scene="coast" time="dusk" label="Sunset over Milos" />

The art is drawn at 3:2 and cropped to fit its frame (preserveAspectRatio="xMidYMid slice"), so any aspect ratio works. To use photos, put an <img> (or next/image with unoptimized) inside the same .art-frame box instead (cards use 4:3, region sections 4:3, the tour hero 21:9 on desktop). The .art-frame class adds a subtle paper grain over whatever is inside.

Other artwork: RouteMap draws the tour page map from stops, Avatar draws portraits from a "skin,hair,style,background" string (style is short, long, bun, curly or bald), Postmark is the round company stamp and LogoMark the logo.

The social image is src/app/opengraph-image.png (1200×630); replace it with a PNG of the same size. The favicon is src/app/icon.svg.

The menu items are NAV in src/data/site.ts, used by the desktop menu and the mobile menu (which adds Home). SiteHeader marks the current page with aria-current="page". The footer columns are TRIPS and COMPANY in src/components/SiteFooter.tsx. The announcement bar is in src/app/layout.tsx. To add a page, create src/app/<route>/page.tsx, export its metadata with pageMeta() and add it to NAV.

Features#

Trip finder#

src/components/TripFinder.tsx. The form #trip-finder on the home page has four selects (region, month, style, duration). On submit it opens /tours with only the chosen values as URL parameters, for example /tours?region=japan&month=4. Without JavaScript the form still submits to /tours with GET.

Tour filters#

src/components/ToursBrowser.tsx with the logic in src/lib/tours.ts. Checkboxes named region, style and duration (several in one group combine with OR, groups combine with AND), a month select and a max budget slider. #sort orders by recommended, price, length or rating (SORTS). Every change updates the grid, the live count #result-count, the removable chips in #active-filters and the small count next to each option (how many trips you'd see if you ticked it), and writes the state to the URL with history.replaceState:

/tours?region=morocco,japan&style=hiking&duration=medium&month=5&max=3000&sort=price-asc

readState() reads the URL: names and values are case-insensitive, lists can be comma-separated or repeated, and unknown values are ignored; writeState() builds the query string. When nothing matches, #tour-empty appears with a reset button (clearing keeps the sort). Removing a chip moves focus to the next chip or the result count. On phones the filters are behind #filters-toggle (aria-expanded), with a badge showing how many are active. When a month is chosen, card links carry it (/tour?id=…&month=5) so the tour page preselects a departure in that month.

The page is a static export, so the URL is read in the browser: ToursFromUrl uses useSearchParams() inside a <Suspense> boundary, and the exported HTML shows all tours until then.

Tour page#

src/components/TourDetail.tsx. ?id= picks the tour (case-insensitive); a missing or unknown id shows the first tour. The tab title and meta description are set in the browser from the tour shown. The itinerary is a list of buttons with aria-expanded and aria-controls; the first day starts open and the "Expand all days" button opens or closes every day.

Departures and prices#

departures(tour, today) in src/lib/tours.ts works out the next eight departures: in each month listed in tour.months, a departure leaves on the first Saturday on or after each day in DEPARTURE_DAYS, skipping any sooner than BOOKING_CUTOFF days from today. They are worked out in the browser (with today's date), so a site built months ago still shows upcoming dates. Months rated 3 ("best") in the region's season cost PEAK_SUPPLEMENT more and are marked "Peak". Places left are demo data from a stable hash of the tour and date, so they are the same on every visit: 0 shows "Full" (and is disabled in the widget), 1–3 "Only N left", and four or more travellers booked shows "Guaranteed". Replace departures() with your real dates if you have them; the table and widget only need objects of the type Departure ({ start, end, month, price, peak, left, guaranteed }).

Booking widget and enquiry#

The #booking-widget card has a departure select (#dep-select), a travellers stepper (1 to MAX_TRAVELLERS, never more than the places left) and a room choice. quote(tour, departure, travellers, room) returns the price per person, the own-room supplement, the total and the deposit, shown live in an aria-live list. "Choose" buttons in the dates table select a departure, scroll to the widget and show a toast. The current choice is written to the summary above the enquiry form and to its hidden fields (tour, departure, travellers, room, total), so a connected form receives everything. On phones a fixed bar at the bottom shows the total and links to the widget. ?date=YYYY-MM-DD, ?month=5 and ?travellers=3 preselect choices.

Seasonality#

src/components/Seasons.tsx. SeasonTable renders the #season-table table (a row per region, a cell per month, each with a screen-reader label such as "April: Best") and SeasonBar a 12-bar chart with the list of best months for one region, all from REGIONS[].season. The table scrolls sideways on small screens with the region names kept in view.

Journal filter#

src/components/JournalStories.tsx. Buttons with data-topic-btn and aria-pressed show only the stories with that topic; #journal-status announces the result and the URL keeps ?topic=. To add a story, add an entry to POSTS in src/data/content.ts. Every story links to the one demo article, /article.

Scroll reveal, ticker, progress and back to top#

Elements with .reveal fade in when scrolled into view (RevealObserver, including content rendered later; everything above is revealed too). The home page ticker scrolls with CSS (.ticker) and pauses on hover; the hero stamp floats (.float). On /article, ReadProgress grows with how far you have read through [data-article]. Reveals, the ticker and the float stop for visitors who prefer reduced motion. To remove the back-to-top button, delete <BackToTop /> from layout.tsx and the .back-to-top styles.

Forms#

All three forms use useValidatedForm() (src/components/useValidatedForm.ts) with the rules in src/lib/validate.ts: errors appear on blur and on submit, update as you type once shown, the first invalid field is focused, and each error is in the element <field id>-error, linked with aria-describedby.

Attribute Rule
required Must not be empty (checkboxes must be ticked; message from data-msg)
type="email" Must look like an email address
type="tel" Digits, spaces, +, (, ) and - only, 7 to 20 characters
minLength Minimum length
data-label Words used in the "Enter …" message
Form Where Success state
Newsletter (#newsletter-form) src/components/NewsletterForm.tsx #newsletter-success
Enquiry (#enquiry-form) TourDetail.tsx (/tour#enquire) #enquiry-success, repeating the booking summary
Contact (#contact-form) src/components/ContactForm.tsx #contact-success

Demo forms don't send anything, and the success messages say so. To collect submissions, post the form in onValid() in useValidatedForm.ts before showing the success state, for example to Formspree:

ts
async function onValid(el: HTMLFormElement) {
  const res = await fetch("https://formspree.io/f/your-id", {
    method: "POST",
    body: new FormData(el),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) return toast("Something went wrong. Please email us.");
  // ...then the existing lines: setSent(first), el.reset()
}

(toast is in src/lib/toast.ts.) Then change the success text to say the message was received. Every field has a name, so FormData carries all values; keep a name on any field you add. The contact form's trip list is built from TOURS, and /contact?tour=<id> preselects a trip.

If you drop the static export (see below) you can post to your own Route Handler instead, for example src/app/api/enquiry/route.ts:

ts
export async function POST(req: Request) {
  const data = await req.formData();
  // send an email, save to a database...
  return Response.json({ ok: true, tour: data.get("tour") });
}

SEO and sharing#

Each page exports its own metadata (title, description, Open Graph) with pageMeta() from src/lib/metadata.ts; the layout adds the site name, Twitter card and metadataBase. Set your domain with SITE_URL when building so the social image URL is absolute:

sh
SITE_URL=https://www.example.com pnpm build

The tour page's exported HTML describes the first tour; the browser updates the title and description for the tour in ?id=. If search visibility for each tour matters, generate one route per tour instead (for example src/app/tours/[id]/page.tsx with generateStaticParams() returning every TOURS id). Consider adding src/app/sitemap.ts, src/app/robots.ts and TouristTrip structured data (JSON-LD). The 404 page is noindex.

Accessibility#

  • Skip link, landmarks, one h1 per page and a logical heading order; breadcrumbs use aria-current.
  • aria-current="page" in the menus; the mobile menu has aria-expanded, closes with Escape and returns focus to its button.
  • The itinerary and FAQ are buttons and <details>; filter options and room choices are real checkboxes and radios.
  • Live regions for the result count, the journal status, the booking total, the toast and form status; errors are linked to their fields and the first one is focused.
  • The seasonality chart is a real table with text for every cell; the region charts and route map have text alternatives; decorative art is hidden from screen readers.
  • Visible focus outlines, AA contrast, and prefers-reduced-motion turns off reveals, the ticker, the floating stamp and smooth scrolling.

When editing, keep one h1 per page, give new inputs a <label> and a name, pass label to art that carries meaning, and keep icon-only buttons' aria-label.

Building and deploying#

pnpm build writes a static site to out/ (output: "export" in next.config.ts). Upload out/ to any static host: Vercel, Netlify, Cloudflare Pages, GitHub Pages or your own server. On Vercel you can also import the project directly; it detects Next.js.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building (the live demo uses /demos/nextjs/wander):

sh
DEMO_BASE_PATH=/trips pnpm build

Pages are exported as tours.html, tour.html and so on; most hosts serve them at /tours. To use server features (Route Handlers, Server Actions, per-request rendering), remove output: "export" and images.unoptimized from next.config.ts and deploy to a host that runs Next.js.

Troubleshooting#

  • A new tour doesn't appear under a filter. Its region and style must match an id in REGIONS and STYLES, and its months must be numbers from 1 to 12.
  • /tour?id=… shows the wrong tour. Unknown ids fall back to the first tour; check the id matches TOURS.
  • A tour shows no departures. Departures are only generated for the next 15 months and never sooner than BOOKING_CUTOFF days away; check the tour's months.
  • Prices look higher than "from". Departures in a region's best months add PEAK_SUPPLEMENT; set it to 0 to turn peak pricing off.
  • The enquiry says nothing was sent. That's the demo. Connect the form in onValid() (see Forms) and update the success text.
  • A character shows in a fallback font. The bundled fonts cover Latin and Latin Extended. For other scripts, add the matching woff2 file to src/fonts/ and a face to src/app/fonts.ts.
  • Links break when the site is in a sub-folder. Set DEMO_BASE_PATH at build time; next/link adds it, and asset() in src/lib/asset.ts adds it to plain URLs such as the trip finder's action.

License#

MIT: free for personal and commercial use. See LICENSE.txt in the download. The SVG illustrations, logo and social image are part of the template and covered by the same license.

Search documentation

Search the guides and every template's documentation