LocalFinds Template - Documentation

Table of Contents

This is the complete documentation for the LocalFinds Astro directory template, covering setup, configuration, Airtable, forms, deployment, theming, and an end-to-end walkthrough. It matches version 1.2.0 of the template. Buyers get this same documentation inside the GitHub repo, along with a changelog that has upgrade notes for every release.

Getting started

This guide gets the template running on your machine and explains how the pieces fit together.

What you’ll need

  • Git. To clone the repo (and pull future template updates). Check with git --version.
  • Node.js version 22.18.0 or newer. Node is the JavaScript runtime the build tools run on. Check your version with node --version.
  • pnpm. The package manager this project uses (like npm, but faster and stricter). Install it once with npm install -g pnpm.
  • An Airtable account. This is where your listings live. A free account is enough to start. (Airtable setup →)
  • A code editor such as VS Code. Optional but recommended.

New to these tools? Astro turns your content into a regular website made of plain HTML files. Airtable is a “spreadsheet that acts like a database” - you’ll edit your listings there in a friendly grid. You don’t need to be a developer to manage content, but the initial setup and deploy do involve the command line.

0. Get the code

Clone the repo to your machine:

git clone https://github.com/search-ventures/localfinds-astro-directory-template.git
cd localfinds-astro-directory-template

Cloning keeps you connected to the template repo, so you can pull future fixes and updates:

git pull        # later — fetch template updates we've pushed

Optional: make it your own independent repo

If you’d rather your project have a clean history with no link back to the template (so your commits and ours never tangle), detach it after cloning:

rm -rf .git        # drop the template's git history
git init           # start fresh
# then create your own (private) repo on GitHub and push to it

The trade-off: you give up git pull-ing our future template updates — you’d have to merge those in manually. Most buyers keep the clone connected; detach only if you specifically want a standalone repo.

Install

pnpm install

If native dependencies misbehave after install (rare), run pnpm rebuild.

Set up your environment

The site needs to know how to reach your Airtable base. Those secrets live in a file called .env, which is never committed to git.

cp .env.example .env

Then open .env and fill in:

AIRTABLE_TOKEN=your_personal_access_token
AIRTABLE_BASE=appXXXXXXXXXXXXXX

Where these come from is covered step-by-step in Airtable setup. These credentials are only used by pnpm run refresh:airtable (and the runtime form endpoints) — not by dev, build, or deploy.

Pull your content

The site builds from a committed snapshot of your Airtable data, not a live fetch. Pull your data once before running:

pnpm run refresh:airtable

You’ll see Fetched N items from Airtable "Items" → Wrote N items to src/data/airtable-snapshot.json. This is the only command that calls Airtable. Re-run it whenever you change your Airtable content.

First run: the repo ships with the demo’s snapshot, so the site runs before you do anything. Running refresh:airtable replaces it with your base’s content.

Run it

pnpm dev

Open http://localhost:4321 . (If that port is busy, Astro picks the next free one and prints the URL.)

On startup you’ll see it load your content from the snapshot, e.g. Loaded 15 items from src/data/airtable-snapshot.json.

The commands you’ll use

CommandWhat it does
pnpm run refresh:airtablePull Airtable into the committed snapshot (src/data/airtable-snapshot.json) + download images. The only command that calls Airtable. Run it when your content changes.
pnpm devStart the local dev server. Reads the snapshot (no Airtable call).
pnpm buildBuild the production site into dist/ from the snapshot (no Airtable call).
pnpm previewServe the built site locally using the real Cloudflare runtime. Good for a final check before deploy.
pnpm exec astro checkType-check the whole project (catches mistakes the editor might miss).

There is no test runner or linter configured - type-checking via astro check is the safety net.

How it’s built (the mental model)

  Airtable base        refresh (manual)            committed snapshot           build step              your website
 ┌───────────────┐   ┌────────────────────┐   ┌──────────────────────┐   ┌─────────────────┐   ┌──────────────────┐
 │ Items         │   │ refresh:airtable    │   │ airtable-snapshot.json│   │ src/loaders/    │   │ static HTML pages │
 │ Cuisines      │──►│ • fetches all tables│──►│ src/airtable-images/  │──►│   airtable.ts   │──►│ in dist/          │
 │ Areas         │   │ • resolves→slugs    │   │ (both committed       │   │ • reads snapshot│   │ • /place/<slug>   │
 │ Tags + images │   │ • downloads images  │   │  to git)              │   │   — no API call │   │ • /map, /blog ... │
 └───────────────┘   └────────────────────┘   └──────────────────────┘   └─────────────────┘   └──────────────────┘
        only this step calls Airtable ▲
  1. You manage content in Airtable - listings, categories, photos.
  2. You run pnpm run refresh:airtable when content changes. It pulls every table, turns Airtable’s record links into clean URL slugs, downloads images locally (Airtable’s image URLs expire, so they’re never linked directly), and writes it all to a committed snapshot. This is the only step that calls Airtable.
  3. The build reads the snapshot. The loader (src/loaders/airtable.ts) reads src/data/airtable-snapshot.json — dev, build, and deploy make zero Airtable calls. Astro renders static pages, using src/site.config.ts for the vertical-specific labels and URLs.

The consequence: a content change in Airtable shows up only after you refresh the snapshot and rebuild. In development: pnpm run refresh:airtable, then restart pnpm dev. In production: refresh, commit, and deploy (you can automate this - see deployment).

Why a snapshot? Fetching Airtable on every build/dev-reload spends API calls fast — enough to exhaust Airtable’s free tier (1,000/month) on a busy or frequently-rebuilt site. The snapshot decouples your build from Airtable: free, fast, and offline-capable.

The only server-side parts are the three form endpoints; everything else is plain static files.

Project structure

src/
  site.config.ts        ← THE file you customise (vertical, brand, taxonomies, nav, SEO)
  loaders/airtable.ts   ← reads the committed snapshot at build time (no API call)
  loaders/airtable-fetch.ts ← live Airtable fetch, used only by refresh:airtable
  content.config.ts     ← defines the "items", "pages", and "blog" collections
  data/airtable-snapshot.json ← committed snapshot of your Airtable data
  data/items.ts         ← typed accessor used by pages (getItems, getItem, …)
  layouts/              ← BaseLayout (site shell) + ContentLayout (MDX/blog)
  components/           ← PlaceCard, CoverImage, Map, StatusBadge, FilterSidebar, …
  pages/                ← routes (see below)
    index.astro         ← homepage
    listings.astro      ← all listings + filters
    map.astro           ← map view
    [itemBase]/[slug].astro   ← a listing's detail page (e.g. /place/<slug>)
    [taxonomy]/[slug].astro   ← a category browse page (e.g. /cuisine/<slug>)
    [slug].astro        ← catch-all for MDX "pages" (About, Privacy, …)
    blog/               ← blog index + posts
    api/                ← submit / contact / subscribe form endpoints (server-side)
  content/
    pages/*.mdx         ← static pages (About, Privacy)
    blog/*.mdx          ← blog posts
    assets/blog/        ← blog cover images
  styles/global.css     ← Tailwind theme tokens + prose styles
  airtable-images/      ← images pulled by refresh:airtable (committed to git)
public/                 ← static files served as-is (favicons, robots.txt, og image)
docs/                   ← you are here

Next

→ Configuration reference - make it yours.


Configuration reference

Everything that makes this directory yours lives in one file: src/site.config.ts. This guide documents every field.

The components and routes never hard-code “restaurant” anything - they read this config. So to re-purpose the template for a different industry, you edit this file (and your Airtable labels), and the whole site re-labels itself. For an end-to-end example see the restaurants → hairdressers walkthrough.

After editing this file, restart pnpm dev (and rebuild for production). The config is read at build time.

Contents


Stable keys vs. labels

This is the one concept that makes safe re-labelling possible. Throughout the config you’ll see both a key and a label:

  • key (e.g. cuisine, area, tag) is a stable internal identifier used in code. Never rename a key once you have content and live URLs depending on it - it’s wired into the loader and components.
  • label / plural / slugBase are human-facing - the words shown in the UI and used in URLs. Rename these freely.

So a “cuisine” taxonomy can display as “Specialty” and live at /specialty/... while the code still calls it cuisine under the hood. You change the labels; the key stays put.

Important: the loader matches Airtable fields and tables by name, derived from these labels (details in Airtable setup). If you rename a label/plural here, rename the matching Airtable table/field to match - or the loader won’t find it.


Brand & identity

brand: 'LocalFinds',
brandIcon: 'ph:map-trifold',
city: 'Sunshine Coast',
vertical: 'restaurants',
FieldTypeWhat it does
brandstringYour site name. Shown in the header logo, footer, page titles, and social cards.
brandIconstringAn optional Phosphor icon shown before the brand text in the header, e.g. ph:map-pin, ph:storefront, ph:scissors. Set to '' to hide it. The icon inherits the brand colour.
citystringThe place your directory covers. Appears in titles, SEO copy, and the map default.
verticalstringA free-text label for your own reference (restaurants, salons, …). Not shown to visitors; just documents what this config is for.

Finding an icon name: browse phosphoricons.com, click an icon, and use its name prefixed with ph: (the icon “map-trifold” → ph:map-trifold). The same ph:-prefixed names work anywhere the config takes an icon (header menu, brand).


Theme (colours)

theme: {
  primary: '#69268E',
  primaryHover: '#35268e',
},
FieldTypeWhat it does
primaryhex colourThe main accent - buttons, links, active states, the hero card, filter highlights, the brand logo.
primaryHoverhex colourThe darker shade used on hover for primary buttons/links.

These two values recolour the entire site. They’re injected as CSS variables at runtime, so every accent in the UI follows them. Change these two lines to rebrand. More in theming & fonts.


Announcement bar

announcement: '',

A thin bar across the very top of every page (above the header). Put a string here to show it (e.g. '🎉 Now covering the whole coast!'); leave it '' to hide the bar entirely.


Hero (homepage)

heroTitle: 'The Best of Sunshine Coast',
heroBlurb: 'A hand-picked list of the very best places to eat and drink…',
heroCtas: [
  { label: 'Browse all places', href: '/listings', style: 'primary' },
  { label: 'Open the map', href: '/map', style: 'ghost' },
],
FieldTypeWhat it does
heroTitlestringThe big headline in the homepage hero card.
heroBlurbstringThe supporting sentence under the headline.
heroCtasarrayThe call-to-action buttons in the hero. Each has a label, an href, and a style of 'primary' (filled) or 'ghost' (outline). Use [] to hide the button row.

SEO & social

seo: {
  defaultOgImage: '/og-default.png',
  defaultDescription: 'A hand-picked directory of the best cafes…',
  locale: 'en_AU',
  twitter: '',
  taxonomyDefault: {
    title: '{value} {plural} in {city}',
    description: 'The best {city} {pluralLower} for {valueLower} - hand-picked.',
  },
},

These drive the <meta> description, Open Graph + Twitter cards, and are the site-wide fallbacks (individual pages set their own where it makes sense).

FieldTypeWhat it does
defaultOgImagepathThe image shown when your site is shared on social media. Replace public/og-default.png with a branded 1200×630 PNG or JPG (social networks don’t display SVG share images).
defaultDescriptionstringThe meta description used on the homepage and any page without its own.
localestringThe Open Graph locale, e.g. en_AU, en_GB, en_US.
twitterstringYour @handle for Twitter/X cards. Leave '' to omit it.
taxonomyDefaultobjectFallback SEO templates for category browse pages - see below.

Taxonomy SEO templates

Category pages (/cuisine/..., /area/..., /tags/...) generate their own titles and descriptions from templates. Each taxonomy can define its own seo (see Taxonomies); seo.taxonomyDefault is the fallback for any taxonomy that doesn’t.

Templates use these tokens, substituted per page:

TokenBecomesExample
{value}the category value, Title CasedItalian, Noosa Heads
{valueLower}the value, lower-cased (reads better mid-sentence)margaritas
{plural}item.pluralPlaces to Eat
{pluralLower}item.plural, lower-casedplaces to eat
{city}citySunshine Coast
{count}number of matching listings12

Example: the template 'The Best {value} {plural} in {city}' on the cuisine page for “italian” renders as “The Best Italian Places to Eat in Sunshine Coast”.

Category pages that end up with zero matching listings are automatically marked noindex (kept out of search engines), so you never publish thin/empty pages.


Organization (structured data)

organization: {
  name: 'LocalFinds SC',
  logo: '/favicon.svg',
},

Used for JSON-LD structured data (the Organization schema on the homepage/About, and as the publisher on blog posts). name is your organisation’s name (can differ from brand if your legal/org name is different) and logo is a path in public/.


Map

map: {
  style: 'https://tiles.openfreemap.org/styles/positron',
  center: [153.066666, -26.650000],   // [longitude, latitude]
  zoom: 12,
},

The map view (/map) and the per-listing mini-map use MapLibre GL with free, keyless map tiles - no account or API key needed.

FieldTypeWhat it does
styleURLThe map style. The default positron is a clean, muted, label-light style so your markers stand out. Alternatives from OpenFreeMap: swap positron for liberty (full Google-Maps-style POIs) or bright.
center[lng, lat]Where the map centres by default. Note the order: longitude first, then latitude. Find coordinates at latlong.net.
zoomnumberInitial zoom (higher = closer). 12 is roughly city-level.

Only listings that have Lat and Lng values in Airtable get a map marker.

Upgrade path: to eliminate any third-party tile dependency later, you can self-host Protomaps .pmtiles on Cloudflare R2 and point style at it - the pmtiles:// protocol is already wired in. This is optional; the default works out of the box.


Item (your listing)

This is the thing your directory lists - a restaurant, a salon, a garage. Generically, an “Item”.

item: {
  singular: 'Place to Eat',
  plural: 'Places to Eat',
  slugBase: 'place',          // detail pages live at /place/<slug>
  highlightLabel: 'Why we like it',
  showHours: true,
  showPrice: true,
  showMap: true,
},
FieldTypeWhat it does
singularstringWhat you call one listing (Place, Salon, Garage). Used in headings and copy.
pluralstringThe plural form. Flows into titles, the “All {plural}” pages, SEO templates, footer text, and buttons.
slugBasestringThe URL segment for detail pages. place → /place/the-old-spence. Set it to whatever reads well for your vertical (salon, garage).
highlightLabelstringThe label for the editorial “why we picked this” line on detail pages.
showHoursbooleanIf true, shows the Open/Closed status pill (driven by the Hours field) and the “Open now” filter. Turn off for verticals without opening hours.
showPricebooleanWhether to show the price tier (see Tiers).
showMapbooleanWhether to show the map on detail pages (for listings that have coordinates).

Each per-listing field degrades gracefully - if a listing has no hours, phone, or photo, that part simply doesn’t render. So you can omit fields your vertical doesn’t use.


Taxonomies (your categories)

Taxonomies are the ways you slice your listings - by cuisine, by area, by tags. Each taxonomy becomes:

  • an Airtable table (where you manage the category values),
  • a URL segment + browse page (/cuisine/italian),
  • a filter group on the listings page,
  • a footer column.
taxonomies: [
  {
    key: 'cuisine', label: 'Cuisine', plural: 'Cuisines', slugBase: 'cuisine',
    multi: false, filter: true, browse: true,
    seo: {
      title: 'The Best {value} {plural} in {city}',
      description: 'The best {value} {pluralLower} in {city} - hand-picked.',
    },
  },
  {
    key: 'area', label: 'Area', plural: 'Areas', slugBase: 'area',
    multi: false, filter: true, browse: true,
    seo: {
      title: 'The Best {plural} in {value}',
      description: 'The best {pluralLower} in {value}, {city} - hand-picked.',
    },
  },
  { key: 'tag', label: 'Tag', plural: 'Tags', slugBase: 'tags', multi: true, filter: true, browse: true },
],
FieldTypeWhat it does
keystringStable internal id - never rename. (Why)
labelstringSingular human label (UI, filter group heading, breadcrumbs).
pluralstringPlural label (nav, footer column, and the default Airtable table name).
slugBasestringURL segment for this taxonomy’s browse pages: cuisine → /cuisine/<slug>.
multibooleanfalse = a listing has at most one (cuisine, area); true = a listing can have many, shown as multiple chips (tags).
filterbooleanShow this as a filter group on the listings page.
browsebooleanGenerate browse pages (/{slugBase}/{value}) and a footer column for it.
seoobject (optional)Per-taxonomy SEO title/description templates (tokens). Omit to use seo.taxonomyDefault.

You can add, remove, or rename taxonomies. Want “Specialty / Area / Service” instead of “Cuisine / Area / Tags”? Change the labels (keep keys), and rename the Airtable tables to match. Want a fourth taxonomy? Add an entry with a new key and create the matching Airtable table. Want fewer? Remove an entry and its table.

How a taxonomy connects to Airtable: the loader looks for a table named after the taxonomy’s plural, and a link field on the Items table named after the label (for single-value) or plural (for multi-value). Defaults: table Cuisines + Items field Cuisine; table Tags + Items field Tags. Full mapping →


Tiers (price)

An optional single dimension for price level ($–$$$$).

tiers: {
  enabled: true,
  field: 'price',
  options: ['$', '$$', '$$$', '$$$$'],
},
FieldTypeWhat it does
enabledbooleanTurn the whole price feature on/off. Set false for verticals where price tiers don’t apply.
fieldstringInternal field key - leave as is.
optionsstring[]The tier labels, in order. These must exactly match the Tier single-select options in Airtable. Use ASCII $ (not £/€) to avoid text-encoding problems - display currency is cosmetic.

Blog

blog: {
  enabled: true,
  label: 'Blog',
  slugBase: 'blog',   // posts at /blog/<slug>
},
FieldTypeWhat it does
enabledbooleanWhether the blog exists.
labelstringWhat it’s called in nav (Blog, Guides, News).
slugBasestringURL segment for the blog. Note: posts are MDX files in src/content/blog/. slugBase controls the links; to actually move the route folder you’d rename src/pages/blog/ to match.

Blog posts are authored as MDX with frontmatter (title, description, author, cover, dates, draft, featured, and a newsletter toggle for the sign-up band at the foot of each post). See src/content/blog/ for examples.


header: {
  showSearch: true,
  menu: [
    { label: 'All', href: '/listings', icon: 'ph:squares-four' },
    { label: 'Map', href: '/map', icon: 'ph:map-trifold' },
    { label: 'Blog', href: '/blog', icon: 'ph:notebook' },
    { label: 'About', href: '/about', icon: 'ph:info' },
    { label: 'Submit', href: '/submit', icon: 'ph:cursor' },
  ],
},
FieldTypeWhat it does
showSearchbooleanShow the search box in the header.
menuarrayThe header navigation links - the single place to add/remove/reorder them.

Each menu item has:

Item fieldWhat it does
labelThe link text.
hrefWhere it goes.
iconOptional ph:-prefixed Phosphor icon shown before the label.
style'link' (default plain text) or 'button' (a filled pill, e.g. a “Subscribe” CTA).

On mobile the menu collapses into a hamburger automatically. Delete a line to remove that link.


footer: {
  website: [
    { label: 'Home', href: '/' },
    { label: 'All Listings', href: '/listings' },
    // …
  ],
},

footer.website is the explicit “Website” column of footer links (same NavItem shape as the header menu). The other footer columns (one per browsable taxonomy - Cuisines / Areas / Tags) are generated automatically from your taxonomies and Airtable values, so they stay in sync without manual editing.


Next

→ Airtable setup - connect your content.


Airtable setup

Your listings live in Airtable - think of it as a spreadsheet that behaves like a database. You edit listings there in a friendly grid, then run pnpm run refresh:airtable to pull them into a committed snapshot the site builds from.

There are two ways to get a base: copy the ready-made one (fastest) or build it from scratch (full control). Either way you’ll finish by creating an access token.

Reminder: content changes appear only after you refresh the snapshot and rebuild. The build itself never calls Airtable - see how it builds. See also the build model.


Option A - copy the example base (fastest)

The template ships with a fully-populated example base (the Sunshine Coast restaurant guide). Copying it gives you the exact table/field structure the loader expects, already filled with sample data you can edit or replace.

airtable screenshot

Example base: open the shared LocalFinds base on Airtable

To use it:

  1. Open the shared base link above.
  2. Click Copy base (top-right) to duplicate it into your own Airtable workspace. This makes you the owner with full edit rights.
  3. Open your copy and grab its base id from the URL (the part starting with app, e.g. https://airtable.com/appXXXXXXXXXXXXXX/...).
  4. Continue to Step 3: create an access token.

Then edit the sample listings to make it your own.

If you’re switching verticals (e.g. to hairdressers), copy the base first, then rename its tables/fields to match your config labels - see the walkthrough.


Option B - build the base from scratch

Create a new base in Airtable and add these tables. Field names matter - the loader matches Airtable fields by name (not column order, so you can reorder columns freely).

Table: Items - your listings

The main table. One row per listing.

FieldAirtable typeRequiredNotes
NameSingle line text✓The listing’s name. Make this the table’s primary field.
SlugSingle line text✓URL-safe id, e.g. bella-venezia. Lowercase, hyphens, unique. The detail page URL becomes /<slugBase>/<Slug>.
DescriptionLong text–Short summary shown on cards and as the page meta description.
BodyLong text–Long-form detail-page content. Markdown is supported.
HighlightLong text–The editorial “why we like it” line (detail page only).
TaglineSingle line text–A short italic strapline under the name.
TierSingle select–Price level. Options must match tiers.options in config: $, $$, $$$, $$$$.
HoursLong text–Opening hours as JSON (see below). Drives the Open/Closed pill.
AddressSingle line text–Postal address (also used for the “Directions” link).
PhoneSingle line text–Phone number (used for the “Call” link).
WebsiteURL–External website (the “Visit website” button).
LatNumber–Latitude (decimal). Needed for a map marker.
LngNumber–Longitude (decimal). Needed for a map marker.
CoverAttachment–One image - the card thumbnail + detail hero.
GalleryAttachment–Extra images - the detail-page thumbnail row.
FeaturedCheckbox–Tick to feature on the homepage.
PublishedCheckbox–Controls visibility. A row is published unless this is explicitly unchecked - see note.
CuisineLink to Cuisines–Links to the cuisine table (single).
AreaLink to Areas–Links to the area table (single).
TagsLink to Tags–Links to the tag table (allows multiple).

Publishing: the loader skips a row only if Published is explicitly false (unchecked). If you want drafts to be hidden, add the Published checkbox and untick it for drafts. Rows without the field at all are treated as published.

Tables: Cuisines, Areas, Tags - your categories

One table per taxonomy in your config. Each needs just:

FieldAirtable typeRequiredNotes
NameSingle line text✓Display name, e.g. Italian. Primary field.
SlugSingle line text✓URL-safe id, e.g. italian. Becomes /cuisine/italian.
PublishedCheckbox–Same rule as Items: a value is dropped only if explicitly unchecked.

How these connect to config: each taxonomy’s plural is the table name, and the Items link field is the taxonomy’s label (single-value) or plural (multi-value):

Config taxonomyAirtable tableItems link field
{ key: 'cuisine', label: 'Cuisine', plural: 'Cuisines', multi: false }CuisinesCuisine
{ key: 'area', label: 'Area', plural: 'Areas', multi: false }AreasArea
{ key: 'tag', label: 'Tag', plural: 'Tags', multi: true }TagsTags

If you rename a taxonomy’s labels in config, rename the matching table/field to match.

Table: Submissions - the “submit a business” form

The public submit form writes here. (How the form works →)

FieldAirtable typeNotes
Business NameSingle line textPrimary field.
Submitter NameSingle line text
EmailEmail / text
Is OwnerSingle selectOptions: Yes, No.
WhyLong textFree text. Newsletter opt-in is appended here as a note (see forms guide).
StatusSingle selectOptions: New, Reviewing, Live, Rejected. New rows arrive as New.

Table: Contact - the contact form

FieldAirtable typeNotes
NameSingle line textPrimary field.
EmailEmail / text
MessageLong text
StatusSingle selectOptions: New, Replied, Closed. New rows arrive as New.

The Hours field

Hours is a JSON object mapping weekday → "open-close" in 24h time. Days use three-letter lowercase keys (mon–sun); omit a day to mark it closed. Past-midnight ranges (e.g. a bar open till 1am) are handled.

{ "mon": "09:00-17:00", "tue": "09:00-17:00", "fri": "09:00-23:00", "sat": "10:00-23:00" }

This drives the Open / Closing soon / Opening soon / Closed pill and the “Open now” filter. If a listing has no hours, the pill simply doesn’t show.


Step 3: create an access token

The site authenticates to Airtable with a Personal Access Token (PAT).

  1. Go to https://airtable.com/create/tokens.
  2. Click Create token, give it a name (e.g. “LocalFinds site”).
  3. Add these scopes:
    • data.records:read - read your listings (used by pnpm run refresh:airtable).
    • data.records:write - write form submissions (used by the submit/contact endpoints).
    • schema.bases:read - read table structure.
  4. Under Access, add your base.
  5. Click Create token and copy it now (you won’t see it again).

Put it in .env along with your base id:

AIRTABLE_TOKEN=patXXXXXXXXXXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXXX
AIRTABLE_BASE=appXXXXXXXXXXXXXX

Now run pnpm run refresh:airtable. You should see Fetched N items from Airtable "Items" followed by Wrote N items to src/data/airtable-snapshot.json. Then pnpm dev will show Loaded N items from src/data/airtable-snapshot.json on startup.

Keep this token secret. Never commit .env. The token is only needed to refresh the snapshot (and, in production, for the submit/contact forms) - not to build or deploy. See deployment. If a token is ever exposed, revoke it in the tokens page and create a new one.


Editing content

Day-to-day, you just edit rows in Airtable:

  • Add a listing: new row in Items, fill Name + Slug, link a Cuisine/Area/Tags, drag photos into Cover/Gallery.
  • Add a category: new row in Cuisines/Areas/Tags with Name + Slug, then link it from a listing.
  • Feature something: tick Featured on a listing.
  • Hide something: untick Published.

Then run pnpm run refresh:airtable, commit the regenerated snapshot + images, and rebuild to see it live.

How the site reads Airtable

The build does not call Airtable. Your data lives in a committed snapshot:

  1. pnpm run refresh:airtable is the only command that calls Airtable. It fetches the Items + taxonomy tables (~4 API calls), downloads attachment images into src/airtable-images/, and writes src/data/airtable-snapshot.json.
  2. The content loader (src/loaders/airtable.ts) reads that snapshot at build time, so pnpm dev, pnpm build, and every deploy make zero Airtable API calls.
  3. Both the snapshot and the images are committed to git, so a fresh clone builds with no network and no token.
# edit your data in Airtable, then:
pnpm run refresh:airtable
git add src/data/airtable-snapshot.json src/airtable-images
git commit -m "Refresh directory data"
# rebuild / deploy as usual

Why a snapshot instead of fetching at build time? A build-time fetch spends Airtable API calls on every dev hot-reload and every deploy, which quickly exhausts the Airtable Free plan’s 1,000 calls/month. Your directory data only changes when you change it - so refresh on demand. The live-fetch logic lives in src/loaders/airtable-fetch.ts, invoked only by the refresh script.

About images

Airtable’s attachment URLs are signed and expire, so the site never links them directly. pnpm run refresh:airtable downloads each Cover/Gallery image into src/airtable-images/ (committed to git) and stores just the filename; Astro optimises each into responsive WebP at build. So images are stable, self-hosted, and need no network at build - but adding a photo requires a refresh + rebuild to appear. (For a permanent image host at scale, you can later point the downloads at Cloudflare R2.)


Bulk import (optional): the CSV seed script

If you have many listings to load at once, there’s a one-off seed script that replaces the base contents from a CSV:

node --env-file=.env scripts/airtable-seed.mjs your-data.csv --dry-run   # preview
node --env-file=.env scripts/airtable-seed.mjs your-data.csv             # do it

It parses the CSV, derives the distinct Cuisine/Area/Tag values, deletes all existing rows in Items + taxonomy tables, then recreates everything and links them. Photos are not imported (rights + expiring URLs) - add those by drag-drop afterwards.

⚠️ This is destructive - it wipes the base first. Always --dry-run before the real run, and only use it on a base you’re happy to reset. For ongoing edits, just use Airtable directly.

Encoding note: the script uses Node’s fetch (not curl) on purpose - curl in some environments corrupts multibyte characters (é, —, ñ). If you write to Airtable via scripts, prefer Node fetch.


Next

→ Forms - how submit / contact / newsletter work.


Forms

The template has three forms:

FormPageEndpointWrites to
Submit a business/submitsrc/pages/api/submit.tsAirtable Submissions table
Contact/contactsrc/pages/api/contact.tsAirtable Contact table
Newsletter/subscribe (+ sign-up bands)src/pages/api/subscribe.tsButtondown (once enabled)

How they work (the shared pattern)

Almost the entire site is static HTML. These three form endpoints are the only server-rendered parts (marked export const prerender = false), running at request time on the Cloudflare runtime.

Each endpoint follows the same flow:

  1. The visitor submits the form (a normal HTML POST; the page’s small script stamps the spam-guard timing field, so the forms need JavaScript).
  2. The endpoint runs the spam guard (below), validates the inputs (required fields, a valid-looking email, length limits), and writes to Airtable (or Buttondown).
  3. It then redirects back to the form page with a ?status=... flag, and the page shows the matching banner:
    • ?status=ok → success banner
    • ?status=invalid → “please check your entries” banner
    • ?status=error → “something went wrong” banner
    • ?status=ratelimited / captcha / nojs → spam-guard messages (below)

The form pages are static HTML, so the banners are all rendered hidden and a few lines of JavaScript reveal the one matching ?status (see StatusBanner.astro). Each page passes its own wording via a messages object at the top of the page file.

Length limits: names are capped at 200 characters, emails at 254 and messages at 5,000 (LIMITS in spam-guard.ts, also applied as maxlength on the inputs). Requests over 32 KB are rejected before they’re read.

Spam protection (built in)

All three forms run through a shared spam guard (src/lib/spam-guard.ts) before anything is saved. The layers, cheapest first:

  1. Honeypot. A hidden company field real people never see or fill. Bots fill every field - if it’s set, the endpoint pretends success and saves nothing.
  2. Timing. A hidden ts field is stamped by JavaScript when the page loads. A post without it (a script posting directly, or a browser with JavaScript off) gets a “please enable JavaScript” banner (?status=nojs). Submissions that arrive implausibly fast (under ~2.5s - bots) or very stale (over 2 hours - replayed pages) are silently dropped.
  3. Origin check. Form posts must come from your own domain. (Astro also enforces this at the framework level, returning 403 for cross-site posts - so this is belt-and-braces.)
  4. User-Agent check. Requests must look like they came from a real browser, blocking lazy scripted bots (curl, etc.).
  5. Per-IP rate limit. Each IP is capped (default 3 submissions per minute per form) via the Cloudflare Rate Limiting binding. Over the cap, the visitor gets a friendly “wait a minute” banner (?status=ratelimited).
  6. Cloudflare Turnstile (recommended - see below). Cloudflare’s free, privacy-friendly CAPTCHA. Layers 1-5 stop casual bots, but a determined script can fake them all; Turnstile is the one it can’t.

Design note: the bot-shaped rejections (1, the timing window in 2, 3 and 4) return a fake success so a bot gets no signal about what tripped it. Only the rate limit, a failed Turnstile check, missing JavaScript and genuine validation errors show a real message. The honeypot, timing field and Turnstile widget all come from the shared FormGuard.astro component - drop it inside any new form to protect it.

The rate-limit step only runs on the Cloudflare runtime (pnpm build + pnpm preview, and production) - it’s skipped in pnpm dev, where the binding doesn’t exist. The other layers work everywhere.

Turnstile is built in and switches on automatically once both keys are set. Most visitors never see it - it runs invisibly and only shows a checkbox when Cloudflare is unsure about a visitor.

  1. In the Cloudflare dashboard, go to Turnstile → Add widget. Add your domain (and localhost if you want to test locally), choose Managed mode, and create it.
  2. Copy the site key and secret key.
  3. Local dev: add both to .env:
    PUBLIC_TURNSTILE_SITE_KEY=0x4AAAA...
    TURNSTILE_SECRET_KEY=0x4AAAA...
    (Or use Cloudflare’s always-pass test keys listed in .env.example.)
  4. Production: the two keys are needed at different times:
    • PUBLIC_TURNSTILE_SITE_KEY is public and is baked into the pages at build time. Keep it in .env if you build locally, or add it as a build variable if Cloudflare builds from Git (see deployment).
    • TURNSTILE_SECRET_KEY is secret and is read at runtime: wrangler secret put TURNSTILE_SECRET_KEY.
  5. Rebuild and deploy, then submit each form once to check it.

Set both keys or neither. If only one is set, the check is skipped (so your forms keep working) and an error is written to your Worker logs.

For even more protection you can add WAF rate-limit rules at the edge - see deployment.


Submit a business (/submit)

Lets the public suggest listings. Writes a row to the Submissions table with Status: New for you to review.

Fields:

Form fieldRequired→ Airtable field
Your name✓Submitter Name
Email✓Email
Business name✓Business Name
Are you the owner? (Yes/No)✓Is Owner
What makes this business good?–Why
Newsletter opt-in (checkbox)–appended to Why
spam-guard fields (company, ts)hidden- (handled by FormGuard)

Newsletter opt-in: the submit form has an unchecked-by-default “sign me up” checkbox (GDPR-friendly). The Submissions table has no dedicated field for it yet, so consent is recorded as a note appended to Why ([Opted in to newsletter]). To capture it properly, add a Newsletter Opt-In checkbox to the Submissions table and map it in src/pages/api/submit.ts.

Reviewing submissions: open the Submissions table in Airtable and work the Status field (New → Reviewing → Live/Rejected). Approving a submission means manually creating the corresponding Items row - there’s no automatic promotion.


Contact (/contact)

A standard contact form. Writes to the Contact table with Status: New.

Form fieldRequired→ Airtable field
Name✓Name
Email✓Email
Message✓Message
spam-guard fields (company, ts)hidden- (handled by FormGuard)

Reply from your own email client; update the Status field (New → Replied → Closed) to track it.


Newsletter (/subscribe)

The newsletter form is wired but not connected to a provider yet - by design, so you can choose whether to use it. Out of the box it validates the email, runs the honeypot, shows the success banner, and logs the address server-side without sending anything.

It also appears as a sign-up band on the homepage, the About page, and (optionally) the foot of blog posts.

Going live with Buttondown

The template includes ready-to-use Buttondown wiring, commented out. To enable it:

  1. Create a Buttondown account and copy your API key from Settings → API.
  2. Add it to .env (and your deploy secrets):
    BUTTONDOWN_API_KEY=your_key_here
  3. In src/pages/api/subscribe.ts, uncomment the Buttondown fetch block and remove the placeholder console.log line just below it.

That’s the whole hookup - new sign-ups will then be created as Buttondown subscribers.

Using a different provider

Prefer Mailchimp, ConvertKit, Resend, etc.? Replace the commented Buttondown fetch in subscribe.ts with a call to your provider’s API (most accept a POST with the email). The form, validation, honeypot, and banner all stay the same.

Removing it entirely

If you don’t want a newsletter: remove the “Subscribe” links from header.menu / footer.website in config, and set newsletter: false in blog post frontmatter (or leave the endpoint as the harmless no-op it is).


Customising a form’s look

All three pages share src/components/FormSection.astro - the two-column layout (intro on the left, form card + banner on the right). Reuse it for any new form page. The success/error banners are driven by the ?status= query flag described above.


Next

→ Deployment - put it live.


Deployment

The template deploys to Cloudflare Workers via the @astrojs/cloudflare adapter. Most of the site is static, so hosting is fast and inexpensive. You can also adapt it to other hosts (notes at the end).

What gets deployed

  • The built static site (dist/) is served through Cloudflare’s ASSETS binding.
  • The three form endpoints run server-side on the Workers runtime.
  • Configuration lives in wrangler.jsonc.

One-time setup

  1. Install Wrangler (Cloudflare’s CLI) and log in:
    pnpm add -g wrangler
    wrangler login
  2. Set your domain. In astro.config.mjs, change site: from the default *.workers.dev URL to your real domain (this is required for correct sitemap and canonical URLs):
    site: 'https://yourdirectory.com',
  3. Set your secrets as deploy-time secrets (never in the repo). The build needs no Airtable credentials - directory content comes from the committed snapshot (src/data/airtable-snapshot.json; run pnpm run refresh:airtable to update it). These secrets are only needed at runtime by the form endpoints:
    wrangler secret put AIRTABLE_TOKEN   # submit/contact forms write to Airtable
    wrangler secret put AIRTABLE_BASE
    # recommended - Turnstile spam protection (see docs/04-forms.md):
    wrangler secret put TURNSTILE_SECRET_KEY
    # and, if you enabled the newsletter:
    wrangler secret put BUTTONDOWN_API_KEY
    If you don’t use the submit/contact forms, you can skip the Airtable secrets entirely. The endpoints read these at request time, so changing a secret takes effect immediately - no rebuild needed.

Deploy

pnpm run refresh:airtable  # only if your Airtable data changed since last deploy
pnpm build                # builds into dist/ (reads the committed snapshot)
wrangler deploy           # uploads to Cloudflare

Test the production build locally first with pnpm preview (it uses the real Workers runtime, so it catches issues the dev server won’t).

Deploying from Git (Cloudflare Workers Builds)

Instead of running wrangler deploy yourself, you can connect your repo and let Cloudflare build and deploy on every push.

  1. Name your Worker. In wrangler.jsonc, change "name" from localfinds-astro-directory-template to your project’s name (lowercase, dashes). Cloudflare requires the Worker name in the dashboard to match this value. Commit and push.

  2. Connect the repo. In the Cloudflare dashboard go to Workers & Pages → Create → Import a repository, pick your GitHub/GitLab repo, and use the same Worker name as in step 1.

  3. Build settings:

    SettingValue
    Build commandpnpm run build
    Deploy commandnpx wrangler deploy (the default)
    Root directory/

    You don’t need to choose a package manager or Node version. Cloudflare’s default Node version (24) meets the template’s minimum (22.18). Keep pnpm-lock.yaml committed: it’s how the build knows to use pnpm, and it makes every build install the exact tested dependency versions. (npm would ignore the dependency pins in pnpm-workspace.yaml.)

  4. Build variables (Settings → Build → Variables and secrets). The build needs no Airtable credentials, because it reads the committed snapshot. The only build variable is PUBLIC_TURNSTILE_SITE_KEY, if you use Turnstile (it’s baked into the pages at build time).

  5. Runtime secrets (Settings → Variables and Secrets, type Secret), or wrangler secret put as above: AIRTABLE_TOKEN, AIRTABLE_BASE, TURNSTILE_SECRET_KEY and, if used, BUTTONDOWN_API_KEY.

  6. Push to deploy. Every push to your production branch now builds and deploys. To publish Airtable changes: run pnpm run refresh:airtable locally, commit the snapshot and images, and push.

Form spam protection: the rate-limit binding

The public forms include a per-IP rate limiter (part of the built-in spam guard). It uses a Cloudflare Rate Limiting binding declared in wrangler.jsonc:

"ratelimits": [
  { "name": "FORM_RATELIMIT", "namespace_id": "2001", "simple": { "limit": 3, "period": 60 } }
]
  • It’s already configured - no provisioning step, no dashboard setup. It just works once deployed.
  • limit = allowed submissions per period seconds per IP (per Cloudflare location). period must be 10 or 60. The default (3 per 60s) covers a real human’s double-click and a retry while throttling bot floods. Raise limit if you expect legitimate bursts.
  • It only runs on the Cloudflare runtime, so it’s inactive in pnpm dev (the other spam layers still apply) and active in pnpm preview + production.

Optional: extra hardening

The built-in guard is enough for most directories. If you’re a bigger target, you can layer on:

  • Cloudflare Turnstile - a free, privacy-friendly CAPTCHA, already built in. Set two keys to turn it on: Turning on Turnstile. Recommended for every live site.
  • WAF rate-limiting rules - in the Cloudflare dashboard under Security → WAF → Rate limiting rules, add a rule on /api/* (e.g. 10 requests/min/IP → managed challenge). This runs at the edge, before your Worker even executes.

The compatibility flags (don’t remove these)

wrangler.jsonc sets two flags that the site depends on:

  • nodejs_compat - required for the static search component (Pagefind) to render under the Cloudflare adapter. Without it, pages error out during build.
  • global_fetch_strictly_public - outbound fetch (the submit/contact forms calling the Airtable API at runtime) must target public hosts. This is already satisfied; just don’t drop the flag.

Keeping content fresh

Because the site is static and built from a committed snapshot, Airtable edits don’t appear until you refresh the snapshot and redeploy. The flow:

pnpm run refresh:airtable
git add src/data/airtable-snapshot.json src/airtable-images
git commit -m "Refresh directory data"
pnpm build && wrangler deploy

To automate it, run that on a schedule (e.g. a GitHub Actions cron that runs refresh:airtable, commits, and deploys), or trigger it from an Airtable Automation webhook. Don’t wire a build hook that fetches Airtable on every rebuild - that reintroduces the per-build API spend the snapshot exists to avoid. For low-edit directories, refreshing manually when you change content is perfectly fine.

Before you go live

A pre-production checklist:

  • Secrets: AIRTABLE_TOKEN / AIRTABLE_BASE set as Wrangler secrets (only needed for the submit/contact forms; the build doesn’t use them), not in the repo. Rotate the token if it was ever shared.
  • Content snapshot: pnpm run refresh:airtable run and committed so the deploy reflects your latest Airtable data.
  • Domain: site: in astro.config.mjs points at your real domain.
  • Spam protection: the public forms ship with a built-in guard (honeypot, timing, origin/UA checks, per-IP rate limit - see forms). Confirm the FORM_RATELIMIT binding is present in wrangler.jsonc, and turn on Turnstile (two keys). Add WAF rules (above) if you’re a heavy target.
  • Newsletter: wired to a provider, or the Subscribe form removed (see forms).
  • Branding: replace the favicon set, the OG share image (public/og-default.png), and any placeholder copy.
  • Images: pnpm run refresh:airtable downloads Airtable photos into src/airtable-images/ (committed); Astro optimises them at build. For high traffic, consider hosting them on Cloudflare R2.
  • Sitemap/robots: public/robots.txt points at the sitemap; the sitemap is generated from site:. Confirm both resolve on your domain.

Deploying elsewhere

Prefer Netlify, Vercel, or a plain static host? The site is standard Astro:

  • For a fully static host, you can swap the adapter - but note the form endpoints need a server runtime (serverless functions). On Netlify/Vercel, use their Astro adapter so /api/* runs as functions.
  • Set AIRTABLE_TOKEN / AIRTABLE_BASE (and BUTTONDOWN_API_KEY) as environment variables for the form functions only - the build reads the committed snapshot and needs no Airtable access.
  • On content change, run pnpm run refresh:airtable, commit, and let the host rebuild from the updated snapshot.

Next

→ Theming & fonts - make it look like yours.


Theming & fonts

How to change the look: brand colour, fonts, and the map style.

Brand colour

The fastest, highest-impact change. Two hex values recolour the entire site:

// src/site.config.ts
theme: {
  primary: '#69268E',       // main accent: buttons, links, active states, logo, hero
  primaryHover: '#35268e',  // darker hover shade for primary buttons/links
},

These are injected at runtime as the CSS variables --color-main-blue and --color-dark-blue, which every accent in the UI references. So changing these two lines re-themes buttons, links, the logo, filter highlights, the hero card, chips - everything branded - from one place. No CSS editing required.

The variable is named main-blue for historical reasons; it holds whatever colour you set, blue or not.

The colour tokens

Beyond the brand accent, the palette (greys, status colours, backgrounds) is defined as Tailwind v4 theme tokens in src/styles/global.css, inside a @theme { … } block. There is no tailwind.config.js - Tailwind v4 is configured in CSS.

To adjust a neutral or status colour, edit its --color-* token there. Example tokens you’ll find: --color-soft-grey, --color-minimal-grey, --color-dark-grey, --color-status-open, etc. Each becomes a utility class (bg-soft-grey, text-dark-grey, …).

Fonts

Fonts are self-hosted (no Google CDN) via Astro’s font API, declared in astro.config.mjs under the top-level fonts: [...]:

fonts: [
  { provider: fontProviders.fontsource(), name: 'Fraunces', cssVariable: '--font-fraunces', weights: ['400 700'], styles: ['normal'], fallbacks: ['Georgia', 'serif'] },
  { provider: fontProviders.fontsource(), name: 'Inter',    cssVariable: '--font-inter',    weights: ['400 600'], styles: ['normal'], fallbacks: ['system-ui', 'sans-serif'] },
],
  • Fraunces is the display/heading font; Inter is the body font.
  • Astro downloads and optimises them at build time - no external requests at runtime.

To change a font:

  1. Pick a font available on Fontsource (the provider used here).
  2. Edit the name, weights, and fallbacks in the fonts array, and rename the cssVariable if you like.
  3. Update the matching --font-* reference in src/styles/global.css’s @theme block (it maps --font-display / --font-sans to these variables).

The display font’s softer “feel” is dialled in via font-variation-settings on .font-display in global.css - adjust there if your new font has variable axes.

The header logo is the brand text plus an optional brandIcon (see configuration). For an image logo, edit the logo link in src/layouts/BaseLayout.astro to render an <img> instead of the text - but the text+icon lockup keeps things config-driven, so prefer that where you can.

Map style

The map’s look comes from map.style in config:

map: { style: 'https://tiles.openfreemap.org/styles/positron', /* … */ },

Swap positron (clean, muted) for liberty (full POIs) or bright - all free and keyless from OpenFreeMap. See configuration → Map for the self-hosted upgrade path.

Favicons & share image

  • Favicons live in public/ (favicon.svg, favicon.ico, favicon-96x96.png, apple-touch-icon.png, site.webmanifest). Replace them with your own (a generator like realfavicongenerator.net produces the full set).
  • Social share image: replace public/og-default.png with a branded 1200×630 PNG or JPG (not SVG - Facebook, X and LinkedIn won’t show it; public/og-default.svg is the editable source) and update seo.defaultOgImage if you change the filename/extension.

Next

→ Walkthrough: restaurants → hairdressers - see it all come together.


Walkthrough: restaurants to hairdressers

This is the template’s whole reason for existing: take the same codebase and turn it into a completely different directory without touching a single component or route. Here we’ll convert the example restaurant guide into “CutFinds - the best hairdressers in Brighton.”

The work is in two places: the config file and your Airtable labels. Plus one golden rule.

The golden rule

Change label, plural, and slugBase. Never change a key.

Keys (cuisine, area, tag) are internal wiring. You can display the “cuisine” taxonomy as “Specialty” and serve it at /specialty/... while the code still calls it cuisine. This is what lets you re-vertical safely. (Why →)

For our salon directory we’ll repurpose the three taxonomies like this:

Internal key (unchanged)Was (restaurants)Becomes (hairdressers)
cuisineCuisineSpecialty (e.g. Colour, Cuts, Barber)
areaAreaArea (neighbourhoods - unchanged concept)
tagTagsServices (e.g. Balayage, Kids, Walk-ins)

Step 1 - edit src/site.config.ts

Identity

- brand: 'LocalFinds',
- brandIcon: 'ph:map-trifold',
- city: 'Sunshine Coast',
- vertical: 'restaurants',
+ brand: 'CutFinds',
+ brandIcon: 'ph:scissors',
+ city: 'Brighton',
+ vertical: 'hairdressers',

Theme - pick your colours

  theme: {
-   primary: '#69268E',
-   primaryHover: '#35268e',
+   primary: '#0E7C7B',     // your brand colour
+   primaryHover: '#0a5d5c',
  },

Hero copy

- heroTitle: 'The Best of Sunshine Coast',
- heroBlurb: 'A hand-picked list of the very best places to eat and drink…',
+ heroTitle: 'The Best of Brighton',
+ heroBlurb: 'A hand-picked guide to the best hair salons and barbers in Brighton.',
  heroCtas: [
-   { label: 'Browse all places', href: '/listings', style: 'primary' },
+   { label: 'Browse all salons', href: '/listings', style: 'primary' },
    { label: 'Open the map', href: '/map', style: 'ghost' },
  ],

SEO defaults

  seo: {
    defaultOgImage: '/og-default.png',
-   defaultDescription: 'A hand-picked directory of the best cafes, restaurants and bars on the Sunshine Coast.',
+   defaultDescription: 'A hand-picked directory of the best hair salons and barbers in Brighton.',
-   locale: 'en_AU',
+   locale: 'en_GB',
    twitter: '',
    taxonomyDefault: {
      title: '{value} {plural} in {city}',
      description: 'The best {city} {pluralLower} for {valueLower} - hand-picked.',
    },
  },

The taxonomyDefault template already reads generically ({value} {plural} in {city}), so it needs no change - for a “Walk-ins” service it’ll render “Walk-ins Salons in Brighton” / “The best Brighton salons for walk-ins - hand-picked.”

Organization

  organization: {
-   name: 'LocalFinds SC',
+   name: 'CutFinds',
    logo: '/favicon.svg',
  },

Map - recentre on your city

  map: {
    style: 'https://tiles.openfreemap.org/styles/positron',
-   center: [153.066666, -26.650000],   // Sunshine Coast
+   center: [-0.1372, 50.8225],         // Brighton  [lng, lat]
    zoom: 12,
  },

Find your coordinates at latlong.net - remember it’s [longitude, latitude].

Item - what you’re listing

  item: {
-   singular: 'Place to Eat',
-   plural: 'Places to Eat',
-   slugBase: 'place',
+   singular: 'Salon',
+   plural: 'Salons',
+   slugBase: 'salon',          // detail pages now live at /salon/<slug>
    highlightLabel: 'Why we like it',
    showHours: true,            // salons have opening hours - keep it
    showPrice: true,            // keep if you want £ tiers; see below
    showMap: true,
  },

Taxonomies - relabel, keep the keys

  taxonomies: [
    {
-     key: 'cuisine', label: 'Cuisine', plural: 'Cuisines', slugBase: 'cuisine',
+     key: 'cuisine', label: 'Specialty', plural: 'Specialties', slugBase: 'specialty',
      multi: false, filter: true, browse: true,
      seo: {
-       title: 'The Best {value} {plural} in {city}',
-       description: 'The best {value} {pluralLower} in {city} - hand-picked.',
+       title: 'The Best {value} {plural} in {city}',
+       description: 'The best {value} {pluralLower} in {city} - hand-picked.',
      },
    },
    {
      key: 'area', label: 'Area', plural: 'Areas', slugBase: 'area',
      multi: false, filter: true, browse: true,
      seo: { /* unchanged - areas work the same */ },
    },
    {
-     key: 'tag', label: 'Tag', plural: 'Tags', slugBase: 'tags',
+     key: 'tag', label: 'Service', plural: 'Services', slugBase: 'services',
      multi: true, filter: true, browse: true,
    },
  ],

Notice the keys never change (cuisine, area, tag). Only the labels, plurals, slugBases - and the SEO copy reads fine because it’s token-based.

Tiers - keep or disable

Salons can have price tiers (£–££££), so keep tiers.enabled: true. If your vertical has no price concept (say, a directory of parks), set:

  tiers: {
-   enabled: true,
+   enabled: false,
    field: 'price',
    options: ['$', '$$', '$$$', '$$$$'],
  },

(Keep the options as ASCII $ even if you show £ - it avoids encoding issues and is cosmetic. If you do want to change the symbols, change them in both config and the Airtable Tier options to match.)

Update labels to fit the new vertical:

  header: {
    showSearch: true,
    menu: [
-     { label: 'All', href: '/listings', icon: 'ph:squares-four' },
+     { label: 'All Salons', href: '/listings', icon: 'ph:squares-four' },
      { label: 'Map', href: '/map', icon: 'ph:map-trifold' },
      { label: 'Blog', href: '/blog', icon: 'ph:notebook' },
      { label: 'About', href: '/about', icon: 'ph:info' },
      { label: 'Submit', href: '/submit', icon: 'ph:cursor' },
    ],
  },

The footer’s “Website” column you edit by hand; the taxonomy columns (now Specialties / Areas / Services) regenerate automatically.


Step 2 - rename the Airtable labels to match

The loader finds tables and fields by name, derived from your config labels. So after the config edits above, update Airtable to match:

ConfigRename Airtable…
taxonomy cuisine → plural: 'Specialties', label: 'Specialty'table Cuisines → Specialties; Items link field Cuisine → Specialty
taxonomy area (unchanged)leave Areas / Area as-is
taxonomy tag → plural: 'Services'table Tags → Services; Items link field Tags → Services

Mapping rule recap: the table is named after the taxonomy’s plural; the Items link field is named after the label (single-value taxonomies) or plural (multi-value). So a single-value Specialty taxonomy → table Specialties, link field Specialty; a multi-value Service taxonomy → table Services, link field Services.

Everything else in Airtable (the Items fields Name, Slug, Description, Hours, Cover, etc.) stays the same - they’re vertical-agnostic.

Then fill the tables with salon data: add Specialties rows (Colour, Cuts, Barber…), Services rows (Balayage, Kids, Walk-ins…), and Items rows for each salon, linking them up.


Step 3 - rebuild and verify

pnpm dev   # restart so it re-reads config + Airtable

Check:

  • Homepage hero says “The Best of Brighton”; logo shows the scissors icon in your new colour.
  • /listings filters by Specialty / Area / Service.
  • A detail page lives at /salon/<slug>.
  • A browse page lives at /specialty/<slug> and /services/<slug>.
  • Page titles read “The Best Colour Salons in Brighton”, etc.

If a taxonomy filter is empty or a browse page 404s, it’s almost always an Airtable name mismatch - double-check the table/field names against the mapping rule.


What you did not have to touch

  • No component changed.
  • No route file changed.
  • No taxonomy key changed.
  • No loader change.

That’s the design paying off: the vertical is data, not code. The same is true for mechanics (Garage / Service / Area), gyms, vets, florists - relabel and refill.

Optional polish for a new vertical

  • Replace the blog posts in src/content/blog/ (they’re restaurant stories) and the About / Privacy pages in src/content/pages/.
  • Swap favicons and the OG share image (theming).
  • Pick a display font that suits the brand (theming → fonts).

That’s the whole template. Questions about a specific field? Back to the configuration reference.


Troubleshooting & gotchas

Hard-won notes from building and running the template - the kind of thing that costs an afternoon if you don’t know it. Most of these are about the dev environment and toolchain, not your content.

”Edits in Airtable don’t show up”

Expected. The site builds from a committed snapshot of your Airtable data, not a live fetch. Run pnpm run refresh:airtable to pull your latest changes, then restart pnpm dev (in development) or commit the snapshot and redeploy (in production). See the build model and keeping content fresh.

pnpm overrides live in pnpm-workspace.yaml (don’t remove them)

These settings live only in pnpm-workspace.yaml, which both pnpm 10 and pnpm 11 read. pnpm 11 ignores the old pnpm field in package.json, so don’t move them there. If the overrides go missing on a fresh pnpm install, astro dev crashes with module is not defined on every page. If you’re upgrading from an older copy of the template that still has a pnpm block in package.json, delete that block and use the template’s pnpm-workspace.yaml.

These pins keep the build/dev working. If you’re tempted to clean them up, don’t (until the upstream fixes land):

  • @iconify/utils / @iconify/tools - the fix for astro dev throwing module is not defined (HTTP 500 on every page). astro-icon still depends on older iconify packages that pull in a CommonJS debug package the Astro 6 Cloudflare dev runner can’t load. The newer iconify versions dropped debug, which resolves it (see astro-icon issue #277 / PR #278). Drop these overrides once astro-icon ships the fix. Note: production build/preview worked even before this - only astro dev was affected.
  • vite - keeps a single Vite 8 (the version Astro 7 is built on) across Astro and the Tailwind v4 plugin. If you upgrade Astro to a release built on a newer Vite, update this pin to match.
  • allowBuilds / onlyBuiltDependencies (esbuild, sharp, workerd, @astrojs/cloudflare) - pnpm sandboxes dependency install scripts by default; these need theirs to run (sharp = image processing, workerd = Workers runtime). pnpm 11 reads allowBuilds and fails the install with ERR_PNPM_IGNORED_BUILDS without it; pnpm 10 reads onlyBuiltDependencies. Keep both. If native deps misbehave after a fresh install, run pnpm rebuild.

Cloudflare + Pagefind: No such module "node:path/posix"

If pages render empty with Error: No such module "node:path/posix" during prerender, it’s because the Pagefind search component imports node:path/posix, which needs Node compat on the Cloudflare adapter.

Fix: ensure nodejs_compat is in compatibility_flags in wrangler.jsonc (it is by default - don’t remove it).

The other flag there, global_fetch_strictly_public, requires outbound fetch (e.g. to Airtable) to target public hosts - already satisfied; leave it.

Dev server serving stale code (OneDrive / Windows)

If this project lives under OneDrive on Windows, the astro dev Vite/Cloudflare runner sometimes serves a stale dependency cache after you add a package - symptoms like module is not defined or deps_ssr/... does not exist, even though the code is fine.

Fix: clear the caches and restart:

Remove-Item -Recurse -Force node_modules/.vite, node_modules/.astro, .astro
pnpm dev

Use PowerShell’s Remove-Item -Recurse -Force rather than rm -rf for node_modules - OneDrive can lock files mid-delete and leave node_modules half-removed.

Is it a real bug or just a cache artifact? pnpm build, pnpm preview, and pnpm exec astro check use a different code path than the dev runner. If the error disappears under those, it was a dev-cache artifact, not a code bug.

Writing non-ASCII to Airtable (scripts): use Node fetch, not curl

When writing to Airtable from a script (e.g. the seed script), use Node’s fetch - curl in some shells corrupts multibyte UTF-8 (£, é, —, ñ) into replacement characters. The bundled scripts/airtable-seed.mjs already uses Node fetch for this reason.

This is also why the price tiers use ASCII $ rather than £/€ (see configuration → Tiers).

Cloudflare binding types missing

worker-configuration.d.ts is referenced by tsconfig.json but isn’t committed. If your editor complains about missing Cloudflare binding types, generate it:

pnpm generate-types

Type-checking

There’s no linter/formatter configured - type-checking is the safety net:

pnpm exec astro check

Run it before deploying; it catches issues the editor might miss.


← Back to the docs index.