Private Scottish Tour

The Brief

A friend of mine runs a private guiding business in Scotland — small groups, bespoke itineraries, the kind of experience that doesn't exist on a coach tour. He'd built up a client base on word of mouth, but had no real web presence. The goal was a site that could explain what he does, showcase destinations and itineraries, and let potential customers get in touch.

The harder problem was discoverability. People searching for a private Scottish tour search in a lot of different ways: by region, by landmark, by experience type. A site with a single "destinations" page wasn't going to cut it. The content strategy needed to match how people actually search.

Private Scottish Tour homepage

Content Management with Sanity

The content would change: new itineraries added each season, blog posts, updated destination write-ups. And the client needed to be able to manage it without touching code. A headless CMS was the obvious call, and Sanity was the right fit — the Studio UI is clean enough that a non-technical user can actually use it, and the GROQ query language is flexible enough to do interesting things on the frontend.

The schema ended up with 13 document types. The main content hierarchy is destinations, attractions (nested under destinations), and itineraries (which can reference both). Each destination covers a Scottish region with coordinates, a gallery, a long-form introduction, and a collection of experiences. Attractions sit underneath destinations with their own page, their own gallery, and a set of categorisation tags.

Document typePurpose
destinationScottish regions with coordinates, gallery, experiences
attractionPoints of interest nested under a parent destination
attractionTagCategories (e.g. Castles, Whisky, Natural Landscapes)
itineraryMulti-stop tours referencing destinations and attractions
postBlog posts with Portable Text body
homepage / homepageSectionSingleton with reorderable sections, font picker, pinned items
siteSettingsGlobal: logo, contact details, social links

The homepage is a singleton with a list of reorderable sections, each with its own heading, background image, text, and optional "pinned items" (featured destinations, attractions, or itineraries). There's also a heading font picker with five options — the client wanted some control over the look without needing a developer. The first section is always treated as the hero.

Itinerary stops are polymorphic: a single stops array that accepts references to either a destination or an attraction. On the frontend they're distinguished by type and routed accordingly — attractions construct their URL as /destinations/[destSlug]/[attractionSlug], destinations as /destinations/[slug].

Content SEO

The content strategy is really where this site gets interesting. Each document type generates its own indexable page, which means a single "add destination" action in Sanity Studio creates a destination landing page, and adding attractions underneath it creates a page per attraction. Categorisation tags get their own pages too, listing all matching attractions. You end up with a natural hierarchy of location-specific content without any custom code per page.

Every document type exposes an seo field with metaTitle, metaDescription, and ogImage. If the client leaves them blank, the page falls back to a sensible auto-generated title based on the content. For destinations that template looks like "Private Scottish Tour [Title] | Bespoke Tours of Scotland" — not glamorous, but it hits the right keywords.

The point of having attractions, destinations, tags, and itineraries as separate first-class pages is that search intent varies a lot. Someone searching "castle tours Scotland" hits a tag page. Someone searching "Eilean Donan guided tour" should hit the attraction page. Someone searching "Highland private tour" hits a destination page. None of these require manual effort once the content structure is in place.

Technical SEO

Each page generates its metadata with Next.js's generateMetadata() function, pulling from Sanity at request time. OpenGraph and Twitter card tags are included, with locale set to en-GB. Images are served from Sanity's CDN via @sanity/image-url, with LQIP blur placeholders from Sanity's metadata for fast perceived loading.

The more interesting part is structured data. Every page type gets appropriate JSON-LD, generated using the schema-dts package for type safety:

PageSchema type
HomepageWebSite + Organization
Destination pagesTouristDestination + BreadcrumbList
Attraction pagesTouristAttraction
Tag pagesCollectionPage + ItemList
Itinerary pagesTouristTrip + ItemList of stops
Blog postsBlogPosting with datePublished, author as Organization

The sitemap is generated dynamically at /sitemap.ts. It fetches every document with a slug from Sanity, constructs the correct URL per type, sets priority levels (1.0 for the homepage, 0.9 for list pages, 0.8 for destination detail, 0.7 for attractions and tags), and uses each document's _updatedAt timestamp for lastModified. As the client adds content in Sanity, it automatically appears in the sitemap within an hour (Next.js ISR with a 3600s revalidation window). No manual updates needed.

Security headers are set in next.config.ts: HSTS, X-Frame-Options: DENY, content type sniffing prevention, and a Content Security Policy that allows exactly the third-party domains the site actually uses (Google Analytics, Elfsight for reviews, TripAdvisor, OpenStreetMap tiles).

Booking and Email

The booking flow is built around a client-side tour builder. Visitors browse destinations and attractions and add them to a wishlist, stored in localStorage via React Context. They can also add whole pre-built itineraries. On the booking page, the form pre-populates with everything they've selected, and they fill in dates, passenger numbers, pick-up and drop-off locations, and a free-text message.

Pick-up and drop-off are autocomplete fields backed by a JSON file of Scottish towns, with keyboard navigation and click-outside handling. Small detail, but it prevents the client receiving "Fort William" spelled seventeen different ways.

On submission, the form POST hits a Next.js API route that sends two emails via Resend simultaneously. The owner gets a structured HTML email with all the booking details: a table of customer info, the selected itineraries, the added destinations and attractions (with parent destination for context), and the message. The customer gets a confirmation email with their key booking details and a note that they'll hear back within 24 hours. All user input is HTML-escaped before it touches the email template.

Sending both emails in parallel
await Promise.all([
  resend.emails.send({
    from: 'Private Scottish Tour <bookings@privatescottishtour.com>',
    to: [process.env.CONTACT_EMAIL],
    subject: `New Booking Request from ${name}`,
    html: ownerEmailHtml,
  }),
  resend.emails.send({
    from: 'Private Scottish Tour <bookings@privatescottishtour.com>',
    to: [email],
    subject: "We've received your tour request - Private Scottish Tour",
    html: confirmationEmailHtml,
  }),
]);

There's also a map view at /booking/map(excluded from robots.txt) that renders the current tour selection on a Leaflet map, with each destination and attraction plotted from the geopoint coordinates stored in Sanity. It's a nice way for a prospective client to visualise their itinerary before submitting.

Summary

  • Next.js App Router + Sanity CMS with 13 document types
  • Location-based content hierarchy: destinations → attractions → tags, all with individual indexed pages
  • JSON-LD structured data per page type (TouristDestination, TouristAttraction, TouristTrip, etc.)
  • Dynamic sitemap pulling from Sanity with per-document lastModified timestamps
  • Security headers including HSTS, CSP, and X-Frame-Options
  • Client-side tour builder with localStorage persistence
  • Resend email: owner notification + customer confirmation sent in parallel on booking submission
  • Scottish towns autocomplete for pick-up/drop-off with keyboard navigation
  • Leaflet map for visualising tour selections, keyed off Sanity geopoint coordinates