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).
pnpm install
pnpm devOpen 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.mdCustomising#
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:
@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:
--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:
{
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: "" },
],
},idis used in URLs (/tour?id=dolomites-huts,/contact?tour=dolomites-huts): lower case, no spaces.regionmust be theidof aREGIONSentry andstyletheidof aSTYLESentry. 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
RegionIdtype and an entry toREGIONS; the filters, trip finder, seasonality table and home page grid pick it up. Add a matching entry toDESTINATIONSinsrc/data/content.tsfor its section on/destinations(and the home grid's "best" months). The home page grid's tile sizes (TILEinsrc/app/page.tsx) are laid out for six regions. priceis the lowest price per person andsinglethe 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.stopsare route map points:[x, y, name], withxandyas percentages of the map's width and height.itineraryentries are accordion rows:dis the day or range,tthe title,xthe text andstaythe overnight (leave empty to hide it).featured: 1to4puts the tour on the home page in that order and first in "Recommended" sort;badgeadds 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):
<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.
Navigation, header and footer#
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-ascreadState() 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:
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:
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:
SITE_URL=https://www.example.com pnpm buildThe 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
h1per page and a logical heading order; breadcrumbs usearia-current. aria-current="page"in the menus; the mobile menu hasaria-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-motionturns 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):
DEMO_BASE_PATH=/trips pnpm buildPages 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
regionandstylemust match anidinREGIONSandSTYLES, and itsmonthsmust be numbers from 1 to 12. /tour?id=…shows the wrong tour. Unknown ids fall back to the first tour; check the id matchesTOURS.- A tour shows no departures. Departures are only generated for the next 15 months and never sooner
than
BOOKING_CUTOFFdays away; check the tour'smonths. - Prices look higher than "from". Departures in a region's best months add
PEAK_SUPPLEMENT; set it to0to 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 tosrc/app/fonts.ts. - Links break when the site is in a sub-folder. Set
DEMO_BASE_PATHat build time;next/linkadds it, andasset()insrc/lib/asset.tsadds it to plain URLs such as the trip finder'saction.
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.