HMDG · Internal Documentation

HMDG Base Template

The global design system reference for all HMDG UK clinic websites. Every colour, font, heading, button, container, component, image rule, security standard, and agent workflow is documented here.

Astro 6.1 Tailwind v4 Cloudflare Pages 12 AI Agents 6 Skills v2.0.0

Started: March 5 2026  ·  Last major update: April 2026  ·  Do not rename any CSS variable or class unless adding new ones.

01
Workflow

How developers and the support team work with this template. Read this before anything else.

Workflow

Plain English: Two groups of people use this template. Developers use it to build a new clinic website. Support team uses it to fix tickets on a website that is already live. Both groups go through GitHub. GitHub is the single source of truth — no one edits files directly on Cloudflare.

Workflow A
Developers — building a new client site

Every website developer must follow these steps when starting a new clinic project. The base template is the source — every client site is a separate private repository created from a clone of this template. Never edit the base template directly for a client.

  1. 01

    Clone the base template

    Open a terminal on your local computer. Run: git clone https://github.com/felmerald-hmdg/astro-base-template-cloudflare.git PROJECT-NAME — replacing PROJECT-NAME with the client's name.

  2. 02

    Customise locally

    Open the cloned folder in your editor. Customise per the Client Setup Checklist (Section 27 — Phase 2). Run npm install, npm run dev, then test locally.

  3. 03

    Create a NEW private repo on GitHub

    Go to github.com → "New repository". Name it exactly the same as the client (PROJECT-NAME). Set it to PRIVATE. Do NOT initialise with a README — the cloned template already has one.

  4. 04

    Point the local clone at the new repo

    Back in the terminal, inside the cloned folder, run: git remote set-url origin https://github.com/YOUR-ORG/PROJECT-NAME.git — then git push -u origin main.

  5. 05

    Deploy to Cloudflare Pages

    Follow the step-by-step deployment guide in Section 28. The first deploy connects the new private repo to a Cloudflare Pages project. After that, every push to main auto-deploys.

Visual flow

Source

HMDG Base Template

Public template repo on GitHub

git clone

Step 2

Developer's local computer

Customise per Client Setup Checklist (Section 27)

git push

Step 3

New PRIVATE GitHub repo

Named "PROJECT-NAME" (the client)

connect (Section 28)

Live

Cloudflare Pages

Auto-deploys on every push to main

Workflow B
Intercom support team — fixing tickets on a live site

Once a client site is live, ongoing support is handled by the Intercom team. Every support team member must have a GitHub account with access to the client's repository. Fixes are made on a feature branch, opened as a pull request, and merged to main only by Fel. Cloudflare auto-deploys after the merge.

  1. 01

    Receive the ticket in Intercom

    A customer or staff member reports an issue or change request through Intercom. Read the ticket in full and identify which file(s) or component(s) need to change.

  2. 02

    Pull the project locally

    On your local computer, run git clone for the relevant client's GitHub repository (or git pull if you already have it locally). Run npm install, then npm run dev to verify everything still works before you start.

  3. 03

    Make the fix on a feature branch

    Create a new branch named after the ticket, e.g. git checkout -b fix/contact-form-typo. Edit the file(s), test in the browser, and confirm the change addresses the ticket exactly.

  4. 04

    Push the branch to GitHub

    Commit your changes with a clear message describing the fix. Push the branch: git push -u origin fix/contact-form-typo. Then open a Pull Request on GitHub from your branch into main.

  5. 05

    Notify Fel for review

    Tell Fel the PR is ready (Slack, email, or comment on the PR itself). Include the Intercom ticket reference. Wait for review — DO NOT merge yourself.

  6. 06

    Fel merges to main

    Fel reviews the PR, requests changes if needed, and merges the approved PR to main. This is the only human gate in the deploy chain.

  7. 07

    Cloudflare auto-deploys

    The merge to main triggers an automatic Cloudflare Pages build and deploy. Live within 2–4 minutes. Verify the fix on the production URL and update the Intercom ticket as resolved.

Visual flow

Trigger

Intercom ticket

Customer or staff reports an issue / change

git pull

Step 2-3

Local fix on a feature branch

Edit, test in browser, commit

git push + open PR

Step 4

Pull Request on GitHub

From feature branch → main

notify Fel

Human gate

Fel reviews + merges to main

The only manual approval in the chain

auto-deploy

Live

Cloudflare auto-deploys

Live in 2–4 min · update Intercom as resolved

Roles & responsibilities

Quick reference: who is responsible for what step.

Role GitHub access Can clone template? Can push to feature branch? Can merge to main? Owns deploy?
Developer Yes Yes — for new clients Yes No Sets up first deploy in Cloudflare
Intercom support Yes Yes — to fix tickets Yes No No (auto on merge)
Fel Admin Yes Yes Yes — only person Owner of Cloudflare project
Marketing team No No No No Provides inputs (Section 27 Phase 1)
Client No No No No Provides content + signs off
Golden rules — read once, follow always
  • GitHub is the single source of truth. Never edit files directly on Cloudflare.

  • Every client site is a SEPARATE private repo. The base template is never edited for one client.

  • Repository name = "PROJECT-NAME" (the client). Use the same string in Cloudflare Pages so everything stays consistent.

  • Only Fel merges to main. Everyone else opens pull requests.

  • Cloudflare auto-deploys on every merge to main. There is no manual deploy button to press.

  • If something goes wrong, revert the merge commit on GitHub. The previous deploy is restored automatically.

Claude AI Prompt — Developer: start a new client build

I'm starting a new clinic site for [CLIENT NAME] using the HMDG base template. (1) Confirm I have all Phase 1 marketing inputs from Section 27. (2) Walk me through git clone of the base template, npm install, and dev verification. (3) Help me create a new private GitHub repo named exactly [CLIENT NAME], reset the local remote, and push. (4) Then guide me through the Cloudflare Pages deploy steps in Section 28 — including the four PUBLIC_* env vars under Build scope and Retry deployment. Confirm npm run build is clean before I push.

Claude AI Prompt — Support: fix an Intercom ticket

I have an Intercom ticket for [CLIENT NAME]. Ticket details: [PASTE TICKET]. (1) Confirm I have the latest from origin/main with git pull. (2) Help me identify which file(s) need to change. (3) Create a feature branch named fix/[ticket-summary]. (4) Make the fix, test locally with npm run dev, run npm run build to verify. (5) Commit + push the branch. (6) Open a PR on GitHub from the branch into main. (7) Tell me what to write to Fel so they can review + merge. Do NOT merge yourself.

02
Global Colour Variables
Colors
--color-primary #DB003E
Brand

Main brand colour — buttons, links, accents. Change per client.

--color-primary-hover #DD0D48
Brand

Hover state for all primary interactive elements.

--color-accent #FDF2F5
Brand

Light brand tint — section backgrounds and secondary fills.

--color-headline #040303
Content

Near-black — applied to h1–h6 automatically via @layer base.

--color-body #151515
Content

Near-black — paragraphs and all body text.

--color-caption #94A3B8
Content

Slate grey — captions, labels, meta text, secondary copy.

--color-muted #F2F2F2
Surface

Light grey — alternating section backgrounds.

--color-surface #F8FAFC
Surface

Off-white — card and form backgrounds.

--color-border #E2E8F0
Surface

Subtle border and divider colour.

--color-white #FFFFFF
Surface

Pure white — card surfaces, nav, modals.

--color-black #000000
Surface

Pure black — use sparingly.

--color-hero-dark #0a0f1a
Surface

Deep navy-black — hero and dark footer backgrounds on legal and index pages.

Claude AI Prompt — Colours

Update the primary brand colour to [HEX]. Change --color-primary, --color-primary-hover (slightly darker), and --color-accent (very light tint of primary). These three variables are in src/styles/global.css inside the @theme block. Do not change any other colour variables.

03
Font Variables
Fonts
--font-heading

Inter Tight · Condensed display variant · Used on all headings h1–h6

Heading Font

Aa Bb Cc Dd — The quick brown fox

font-family: 'Inter Tight', ui-sans-serif, system-ui, sans-serif

--font-body

Inter · Standard reading variant · Used on all body text, inputs, and buttons

Body Font

Aa Bb Cc Dd — The quick brown fox jumps over the lazy dog. 1234567890

font-family: 'Inter', ui-sans-serif, system-ui, sans-serif

Claude AI Prompt — Fonts

Update the heading font to [FONT NAME]. Change --font-heading in the @theme block of src/styles/global.css. Also update the Google Fonts import link in BaseLayout.astro and src/pages/index.astro to load the new font. Keep --font-body as Inter unless asked to change it.

04
Typography Scale — H1 to H6
Type
/* Applied globally via @layer base — no class needed */
h1 { font-size: clamp(2.5rem,  3vw + 1.8rem,    4.5rem);  line-height: 1.08; letter-spacing: -0.03em;  font-weight: 800; }
h2 { font-size: clamp(2rem,    2.25vw + 1.47rem, 3.5rem);  line-height: 1.12; letter-spacing: -0.025em; font-weight: 800; }
h3 { font-size: clamp(1.5rem,  1.5vw + 1.15rem,  2.5rem);  line-height: 1.2;  letter-spacing: -0.02em;  font-weight: 700; }
h4 { font-size: clamp(1.25rem, 0.75vw + 1.07rem, 1.75rem); line-height: 1.3;  letter-spacing: -0.015em; font-weight: 700; }
h5 { font-size: clamp(1.125rem, 0.375vw + 1.04rem, 1.375rem); line-height: 1.35; font-weight: 600; }
h6 { font-size: clamp(1rem, 0.19vw + 0.96rem, 1.125rem);    line-height: 1.4;  font-weight: 600; }
/* Font: Inter Tight · Colour: --color-headline · Applied automatically */
H1 clamp(2.5rem → 4.5rem) weight 800 lh 1.08

H1 — Expert Clinic Care

H2 clamp(2rem → 3.5rem) weight 800 lh 1.12

H2 — Expert Clinic Care

H3 clamp(1.5rem → 2.5rem) weight 700 lh 1.2

H3 — Expert Clinic Care

H4 clamp(1.25rem → 1.75rem) weight 700 lh 1.3

H4 — Expert Clinic Care

H5 clamp(1.125rem → 1.375rem) weight 600 lh 1.35
H5 — Expert Clinic Care
H6 clamp(1rem → 1.125rem) weight 600 lh 1.4
H6 — Expert Clinic Care
Claude AI Prompt — Headings

Update the H1 heading size. Change --text-h1 in the @theme block of src/styles/global.css. Use the clamp() format: clamp(min, preferred, max). Also update --text-h1--line-height and --text-h1--letter-spacing if needed. Do not change any other heading variables unless asked.

05
Body Text System
Body
--text-base 16px · lh 1.75 Base — Default body text

This is standard body text at 16px. Line-height 1.75 for comfortable reading. Clinic websites serve patients of all ages including older adults, so readability is always the priority. Never reduce body text below 16px.

--text-lg 18px · lh 1.8 Large — Lead paragraphs, intros

This is a lead paragraph at 18px. Use for the first paragraph in a section or anywhere you need more visual weight without a heading. Apply class .section-lead or text-lg.

--text-sm 15px · lh 1.6 Small — Captions, labels, metadata

Small text at 15px for captions, metadata, certifications, and supporting information. Use for dates, addresses, and non-critical copy.

Claude AI Prompt — Body Text

The body text uses --text-base (16px) and --color-body (#151515) set globally in src/styles/global.css. Paragraphs inherit these automatically via @layer base. To change colour update --color-body. To change size update --text-base. Do not go below 16px. Line-height is --text-base--line-height (1.75).

06
Button System
Buttons

On Light Backgrounds

.btn .btn-default

Primary filled. Main CTAs, hero sections, form submits. Always on a light background.

.btn .btn-secondary

Outlined primary. Secondary actions next to btn-default. Light backgrounds only.

On Dark Backgrounds

.btn .btn-white

White filled. Use on dark or brand-coloured backgrounds only.

.btn .btn-transparent

Ghost white outline. Use on image overlays or dark sections only.

Usage

<a href="#" class="btn btn-default">Book Appointment</a>       /* light bg */
<a href="#" class="btn btn-secondary">Learn More</a>          /* light bg */
<a href="#" class="btn btn-white">View Services</a>           /* dark bg  */
<a href="#" class="btn btn-transparent">Read More</a>         /* dark bg  */
<button type="submit" class="btn btn-default w-full">Send</button>
Claude AI Prompt — Buttons

Add a [BUTTON TYPE] button to [SECTION NAME]. Always use both .btn base and a variant class (btn-default, btn-secondary, btn-white, or btn-transparent). Never create new button styles. Buttons are in src/styles/global.css @layer components. Height is 48px and radius is 10px — do not change these.

07
Form Elements
Forms

All contact forms use the reusable <ContactForm /> component (Resend-powered via the same-origin /api/send-email endpoint). All inputs use the .input class. Radius matches buttons (10px). Focus ring uses --color-primary with a 3px offset ring.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

<ContactForm
  id="contact-page"
  submitLabel="Send Enquiry"
  showPhone=true
/>


<label class="text-sm font-semibold text-[--color-headline]">Label</label>
<input  type="text"  class="input" placeholder="..." />
<select              class="input">...</select>
<textarea            class="input" rows="4"></textarea>
Claude AI Prompt — Forms

Add a contact form using the <ContactForm /> component from src/components/ContactForm.astro. Pass a unique id prop (and optionally submitLabel / showPhone). It is Resend-powered — it POSTs to the same-origin /api/send-email endpoint which verifies reCAPTCHA v3 server-side and sends via Resend (RESEND_API_KEY, RESEND_EMAIL_RECEIVER, RESEND_EMAIL_SENDER, RECAPTCHA_SECRET_KEY env vars required). Never build a new form from scratch and never use Netlify Forms, Formspree, or custom handlers. For non-contact form fields, use the .input class on all inputs, selects, and textareas. Labels use class="text-sm font-semibold text-[--color-headline]". Wrap each label + input in a flex flex-col gap-2 div.

08
Container System
Layout

<section> — Full page width · background colour lives here

.container-main — max-width 1340px · padding: 90px/30px desktop · 70px/30px mobile

.container-inner — no padding · content sits flush

.container-main

Primary section wrapper. Max-width 1340px, centred. Padding: 90px 30px desktop, 70px 30px mobile.

.container-inner

Inner content wrapper. No padding. Always nest inside .container-main.

.section

Alias for .container-main. Use on semantic <section> elements.

.container

Max-width 1340px, centred, no padding. Use in nav, footer, and already-padded wrappers.

Claude AI Prompt — Containers

Build a new [SECTION NAME] section using the global container system. Wrap in <section class="bg-[COLOUR]">. Inside use <div class="container-main"> for padding and max-width. Inside that use <div class="container-inner"> for content. Max-width 1340px. Do not add custom padding — use the container system only.

09
Flex Layout System
Flex

Flexbox is the default layout system. Use .flex-layout for all rows. Default gap is 15px (--spacing-gap). Override only when needed.

.flex-layout Default · 15px gap · wraps on small screens
Item A
Item B
Item C
Item D
.flex-layout.flex-layout--row Explicit horizontal row · same as default but declared clearly
Left
Centre
Right
.flex-layout.flex-layout--center Centre-aligned · trust badges, CTA rows
Trust A
Trust B
Trust C
.flex-layout.flex-layout--between Space-between · nav bars, header rows
Logo
Nav Links
CTA Button
.flex-layout.flex-layout--col Vertical stack · form fields, info blocks
Stack 1
Stack 2
Stack 3
.flex-layout.flex-layout--start Top-align items · text+image rows, card columns
Top A
Top B
Top C

Gap Scale

--spacing-gap 15px Default (set automatically)
--spacing-gap-md 24px Cards, grids — override: gap-[--spacing-gap-md]
--spacing-gap-lg 32px Section columns — override: gap-[--spacing-gap-lg]
Claude AI Prompt — Flex Layout

Build a [DESCRIPTION] layout inside [SECTION]. Use <div class="flex-layout"> for the container. Default gap is 15px. Override with gap-[--spacing-gap-md] for 24px or gap-[--spacing-gap-lg] for 32px. Add .flex-layout--center, --between, --col, or --start modifiers as needed. Do not create new flex classes.

10
Card Component
Cards
HCPC Registered

All clinicians are fully registered and hold current professional indemnity insurance.

4.9 Google Rating

Over 500 verified patient reviews. Trusted by the local community for over a decade.

Fast Results

Most patients see measurable improvement within 2–3 sessions. No long waiting lists.

Personal Approach

Every treatment plan is built around your specific goals, lifestyle, and recovery needs.

background var(--color-white)
border 1px solid var(--color-border)
border-radius var(--radius-card) · 12px
padding var(--spacing-card-p) · 28px
box-shadow var(--shadow-card)
hover translateY(-2px) + shadow-raised + border-primary
<div class="card">
  <h4>Card Title</h4>
  <p>Card description.</p>
</div>
/* Always place .card on a muted/accent background — never on white */
/* Hover animation is built in — no extra classes needed            */
Claude AI Prompt — Cards

Add a [N]-card grid to [SECTION]. Use <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"> as the wrapper. Each card uses <div class="card">. Place the grid inside a muted or accent background section. Do not add custom card styles — .card handles all styling and hover.

11
Combined Example — Real Clinic Section

Every part of the design system working together.

Why Choose Us

Expert Care You Can Trust

Our team of HCPC-registered physiotherapists bring decades of combined experience helping patients recover faster, move better, and live without pain.

HCPC Registered

All clinicians are fully registered and hold current professional indemnity insurance.

4.9 Google Rating

Over 500 verified patient reviews. Trusted by the local community for over a decade.

Fast Results

Most patients see measurable improvement within 2–3 sessions. No long waiting lists.

Personal Approach

Every treatment plan is built around your specific goals, lifestyle, and recovery needs.

Source Structure

<section class="bg-[--color-accent]">
  <div class="container-main">
    <div class="container-inner">
      <div class="section-header">
        <span class="eyebrow">Why Choose Us</span>
        <h2>Expert Care You Can Trust</h2>
        <p class="section-lead">...</p>
      </div>
      <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
        <div class="card">...</div>
      </div>
      <div class="flex-layout flex-layout--center gap-4">
        <a class="btn btn-default">Book</a>
        <a class="btn btn-secondary">Learn More</a>
      </div>
    </div>
  </div>
</section>
Claude AI Prompt — New Section

Build a new [SECTION NAME] section for the clinic website. Use the global design system only — no new CSS classes. Structure: <section class="bg-[COLOUR]"> wrapping .container-main wrapping .container-inner. Use .section-header with .eyebrow for the intro. Use .card for features. Use .btn.btn-default for primary CTA and .btn.btn-secondary for secondary. Use existing colour variables only.

12
Utility Classes
Utils
Eyebrow
.eyebrow

Small uppercase label above section headings. Use inside .section-header or standalone above an h2.

Lead text sample.

.section-lead

Large intro paragraph below a heading. 18px, relaxed line-height. Use inside .section-header.

Layout only
.section-header

Wrapper for eyebrow + heading + section-lead. Centres content and adds bottom margin. Use at the top of every section.

Caption text
.caption-light

Small secondary label. Uses --color-caption. For prompt box labels, metadata, and non-critical supporting text on light backgrounds.

Layout only
.icon-dot

Small primary-coloured decorative bullet. Used inside cards above the title. 10px circle in --color-primary.

.class-name
.code-label

Inline code pill. Off-white background with border. For CSS variable names and class names in documentation.

#DB003E
.code-hex

Inline hex/value badge. Muted background. For values, sizes, and short references.

.copy-btn

Copy to clipboard button. Used in all Claude AI Prompt boxes. Handles active state via JS.

<div class="section-header">
  <span class="eyebrow">Why Choose Us</span>
  <h2>Heading Here</h2>
  <p class="section-lead">Intro paragraph.</p>
</div>
Claude AI Prompt — Utility Classes

Use global utility classes for section structure. Every section heading block must use .section-header containing .eyebrow, h2, and .section-lead. Do not create new heading wrapper classes. Use .icon-dot inside cards above the h4/h5. Use .caption-light for prompt labels and metadata. Do not invent new utility classes — extend or reuse existing ones only.

13
Design Tokens — Radius, Shadows, Spacing
Tokens

Border Radius

--radius-card 12px

.card, article, prompt boxes

--radius-btn 10px

.btn, .input, sidebar links

--radius-badge 999px

.eyebrow, badges, pills, tags

Shadows

--shadow-card

Default card shadow

--shadow-raised

Card hover raised state

--shadow-nav

Sticky nav underline

Spacing Tokens

--spacing-section-x 30px Horizontal padding on footer and full-width elements
--spacing-card-p 28px Internal padding on .card components
--spacing-gap 15px Default flex gap in .flex-layout
--spacing-gap-md 24px Medium gap — cards and grids
--spacing-gap-lg 32px Large gap — section columns
Claude AI Prompt — Tokens

Use global design tokens for all radius, shadow, and spacing. Border radius: --radius-card (12px) for cards, --radius-btn (10px) for buttons and inputs, --radius-badge (999px) for pills and badges. Shadows: --shadow-card on cards, --shadow-raised on hover states. Spacing: --spacing-card-p (28px) for card padding, --spacing-gap (15px) default flex gap. Do not hardcode pixel values when a token exists.

14
global.css Structure
CSS

All design tokens and global styles live in src/styles/global.css. The file is split into four blocks. Only edit the correct block for your task.

@theme src/styles/global.css

All CSS custom properties (design tokens). Colours, fonts, radius, shadows, spacing. Change brand colour, fonts, or spacing here. This is the only place tokens should be defined.

Edit when: When changing brand colour, font, radius, shadow, or spacing values.

@layer base src/styles/global.css

Global element resets and default styles. Headings, paragraphs, anchors, and form elements. Applied automatically — no class needed. Heading sizes, line-heights, and colours are set here.

Edit when: When changing default heading sizes, body text size, or element-level defaults.

@layer components src/styles/global.css

Reusable class-based components. .btn, .card, .input, .eyebrow, .section-header, .flex-layout, .container-main, .container-inner, etc. All reusable UI patterns live here.

Edit when: When adding or modifying a reusable class. Never add one-off classes here.

@layer utilities src/styles/global.css

Custom utility classes that extend Tailwind. Use sparingly. Only add here when a Tailwind utility does not exist for the needed pattern.

Edit when: Only when Tailwind cannot achieve the pattern with existing utilities.

/* src/styles/global.css — structure overview */

@theme {
  /* ── Tokens: colours, fonts, radius, shadows, spacing ── */
  --color-primary: #DB003E;
  --font-heading: 'Inter Tight', sans-serif;
  --radius-card: 12px;
}

@layer base {
  /* ── Global element defaults (h1–h6, p, a, input) ── */
  h1 { font-size: clamp(...); }
}

@layer components {
  /* ── Reusable classes (.btn, .card, .input, etc.) ── */
  .btn { ... }
  .card { ... }
}

@layer utilities {
  /* ── Custom utilities (use sparingly) ── */
}
Claude AI Prompt — global.css

When adding a new design token, add it to the @theme block in src/styles/global.css. When adding a reusable class, add it to @layer components. When changing a heading default, edit @layer base. Never add one-off styles to global.css. Never add styles to individual page files unless scoped to that page only.

15
Image Format Rules
Images
1st
.avif Primary format

Always use as the primary <source>. Best compression and quality. Assumed to exist for every image.

2nd
.webp Fallback format

Always include as the second <source>. Wider browser support.

Only if needed
.jpg / .png Last resort

Only use if no modern format is available or there is a clear technical reason. Never as primary.

If a developer uploads .jpg or .png: Show this message — "Fel recommends uploading .avif images. Use this converter: https://hmdg-elementor.flywheelsites.com/"

Loading Rules

loading="eager"

Hero and above-the-fold images only

loading="lazy"

All below-fold images — default

decoding="async"

Always include on all images

width="" height=""

Always set to prevent CLS (layout shift)

Required Pattern

<picture>
  <source srcset="/images/hero.avif" type="image/avif" />
  <source srcset="/images/hero.webp" type="image/webp" />
  <img
    src="/images/hero.webp"
    alt="Descriptive alt text"
    loading="lazy"
    decoding="async"
    width="1600"
    height="900"
  />
</picture>

/* Use loading="eager" for hero / above-fold images only */
Claude AI Prompt — Images

Add an image to [SECTION]. Use the required picture pattern: <picture> with .avif as the first source, .webp as the second source, and a <img> fallback with loading="lazy" decoding="async" and explicit width and height. Use loading="eager" only for the hero image. Never use <img> alone without the <picture> wrapper when .avif and .webp are available.

16
Security Reference
Security
No secrets in frontend code

API keys, tokens, and credentials must never appear in .astro, .ts, or .js files. Use environment variables only.

Use .env for all secrets

Copy .env.example to .env and fill in values. Never commit .env to git — it is already in .gitignore.

External links — rel attribute

All <a> tags with target="_blank" must include rel="noopener noreferrer" to prevent tab-napping attacks.

No unsafe HTML injection

Never use set:html or innerHTML with unsanitised user input. Only use with trusted, controlled content.

No inline event handlers

Never use onclick='', onload='', or similar inline handlers. Use addEventListener in script blocks only.

CSP headers are pre-configured

Content Security Policy is set in BaseLayout.astro (meta tag), public/_headers (Netlify), and vercel.json (Vercel). Update these when adding new third-party services.

Forms need spam prevention

All public forms should include a honeypot field or reCAPTCHA. Never rely on client-side validation alone.

OWASP Top 10 applies at all times

Follow secure coding defaults. No SQL injection risk, XSS risk, or data exposure in any frontend output.

/* Security files in this template */
public/_headers     → Netlify HTTP security headers (CSP, HSTS, X-Frame-Options)
vercel.json         → Vercel HTTP security headers (same set)
.env.example        → Copy to .env — never commit .env
BaseLayout.astro    → CSP meta tag in <head> for all pages

/* Required pattern for all external links */
<a href="https://..." target="_blank" rel="noopener noreferrer">Link</a>
Claude AI Prompt — Security

Review [PAGE/COMPONENT] for security issues. Check: no secrets in markup, all external links have rel="noopener noreferrer", no unsafe set:html or innerHTML, no inline event handlers, forms include spam prevention, environment variables are used for all sensitive values. Flag any issues with severity level and suggest fixes.

17
Agent System

12 specialist AI agents. Each one has a single clear job. Use the review sequence below for every important page build.

Agents

Plain English: Each agent is a specialist that handles ONE thing well — like a relay race where every runner has their own job. Big tasks get passed through several agents in order. Smaller tasks only need one. The list below tells you who handles what.

Standard Review Sequence (Tier 4–5 page builds)

information-architecture-reviewer
ux-architect
ui-designer
frontend-builder
a11y-reviewer
performance-reviewer
performance-optimisation
seo-reviewer
marketing-reviewer
security-reviewer
conversion-reviewer
01 information-architecture-reviewer

CALL THIS AGENT: When planning a new page, sitemap, or URL structure

Decides URL hierarchy, parent/child pages, taxonomy. Confirms the page belongs where you're putting it. Runs BEFORE any design work.

02 ux-architect

CALL THIS AGENT: When mapping user flow before visual design

Maps the user journey (Awareness → Interest → Trust → Intent → Action). Defines what each section needs to achieve and how interactions behave. Plans only — no code.

03 ui-designer

CALL THIS AGENT: When planning visual layout / colour / spacing / typography

Section anatomy, layout structure, token-based colour planning, mobile-first sizing, interactions. Hands a written spec to the builder. Does NOT write code.

04 frontend-builder

CALL THIS AGENT: When writing or editing Astro / Tailwind code

Builds the approved design in clean Astro + Tailwind. Uses existing class system, BaseLayout, semantic HTML, reusable components. No inline styles, no AVIF/JPG (WebP only).

05 a11y-reviewer

CALL THIS AGENT: After frontend-builder has shipped a feature

Checks WCAG 2.1 AA — 4.5:1 contrast, visible focus rings, heading order, keyboard navigation, form labels, 44×44 touch targets, prefers-reduced-motion fallbacks.

06 performance-reviewer

CALL THIS AGENT: Quick perf check after a feature ships

Image formats, DOM weight, lazy loading, JS bundle size, CLS risk. Flags issues but doesn't run the deep audit.

07 performance-optimisation

CALL THIS AGENT: Deep speed audit / before launch

Full Core Web Vitals audit (LCP, CLS, INP), third-party script weight review, hydration strategy. Targets 90+ on PageSpeed mobile. Runs deeper than performance-reviewer.

08 seo-reviewer

CALL THIS AGENT: After build, before launch

H1 hierarchy, meta description, canonical, noindex rules, internal links, structured data (JSON-LD), local SEO signals (NAP, opening hours).

09 marketing-reviewer

CALL THIS AGENT: When messaging / copy / trust signals need review

Value proposition clarity, trust signals (Google rating, accreditations, years in business), service positioning. Applies copy fixes directly.

10 security-reviewer

CALL THIS AGENT: When a form, embed, third-party script, or env var is touched

OWASP Top 10 frontend issues. Forms, scripts, unsafe HTML, exposed secrets, external links missing rel=noopener, CSP coverage.

11 conversion-reviewer

CALL THIS AGENT: Final gate before declaring a page done

CTA clarity, CTA placement, booking flow, friction points, overall journey. Last in the chain — assesses the page in its final state.

12 visual-qa-reviewer

CALL THIS AGENT: Ad-hoc when built output looks off

Supplementary — checks the built result matches the design plan. Catches implementation gaps between ui-designer's spec and what shipped.

Escalation — when a reviewer finds an issue

Visual / styling problem

reviewer flags → ui-designer redesigns → frontend-builder rebuilds

Code-only fix (missing aria-label, rel=noopener, lazy-loading)

reviewer flags → frontend-builder fixes

Copy / messaging weakness

marketing-reviewer applies fix directly

Critical security or accessibility issue

fix + verify with a SECOND pass by the same reviewer

Parallel vs Sequential

Planning agents (IA → UX → UI → builder) MUST run in order — each one feeds the next. Reviewer agents AFTER the build can run in waves:

Wave 1 — parallel

a11y-reviewer · performance-reviewer · seo-reviewer

Wave 2 — parallel

performance-optimisation · marketing-reviewer · security-reviewer

Wave 3 — final

conversion-reviewer (sees the page in its final state)

Claude AI Prompt — Agent Workflow

Review and build this task using the following agent sequence exactly: information-architecture-reviewer → ux-architect → ui-designer → frontend-builder → a11y-reviewer → performance-reviewer → performance-optimisation → seo-reviewer → marketing-reviewer → security-reviewer → conversion-reviewer. Task: [describe the page, section, sitemap, or feature here]. Do not skip any agent. Do not rush into code before planning. If something is weak, improve it automatically.

18
Modes & Agent Tiers

How Claude decides what to do, how big a task is, and which agents to call.

Workflow

Plain English: Modes set the speed (fast vs careful). Tiers set the size (small vs large). The combination tells Claude how thorough to be, and which specialists to involve.

Three Modes

FAST Default

Default for small fixes (Tier 0–2). Diff-only output. Terse responses.

e.g. Fix a typo · Tweak spacing · Add a CTA · Update a colour

SHIP

Triggered by words like "ship", "finalise", "review", "launch", "production". Runs the full agent pipeline.

e.g. Build the homepage · Launch the site · Final review before going live

EXPLORE

Questions, lookups, read-only investigation. No agents called. Terse answers.

e.g. Where is X defined? · How does cookie consent work? · What does this function do?

Six Tiers (size of the job)

Tier 0

Question / lookup

None — answer directly

Trigger words: "explain", "what is", "where is", "show me"

Tier 1

Single-domain fix

1 specialist agent

Trigger words: "fix the H1", "add alt text", "check the CSP"

Tier 2

Visual change with no UX planning

ui-designer → frontend-builder

Trigger words: "add a card", "restyle the button", "fix mobile spacing"

Tier 3

New component or feature

ux-architect → ui-designer → frontend-builder + reviewer(s)

Trigger words: "build a new section", "add a contact form", "interactive widget"

Tier 4

New page or major redesign

Full IA → UX → UI → build → 5+ reviewers

Trigger words: "build the contact page", "redesign the services page"

Tier 5

Homepage / template / structural change

All 12 agents in sequence

Trigger words: "build the homepage", "set up a new client", "rebuild the template"

When in doubt, escalate one tier higher.

Announcement Rule

For Tier 2 and above, Claude must say which agents will run BEFORE doing the work. Example:

Pipeline: ui-designer → frontend-builder

19
Skills Library

Reusable build patterns. Each skill is a step-by-step recipe Claude follows when the work matches.

Skills

Plain English: A "skill" is a written recipe. When Claude is asked to build something we've solved before, it loads the matching recipe instead of reinventing the work. All skills live in .claude/skills/ and are read on demand.

01 .claude/skills/contactform.md

Resend Contact Form

USE WHEN: Building any contact / enquiry / booking form

Same-origin POST to /api/send-email → server-side reCAPTCHA v3 verify + Resend send. Client + server field validation, honeypot, replyTo, isSubmitting guard, CRLF stripping. Production-tested. Replaces every contact form pattern.

02 .claude/skills/hero-video.md

Hero Video — MANDATORY MP4 H.264

USE WHEN: Adding any hero or background video

Self-hosted MP4 H.264 only. Never YouTube, never WebM-only. JS-injected source for Safari safety. Mobile + reduced-motion get poster only — zero video bytes. Use npm run optimise-video.

03 .claude/skills/performance.md

Site-Wide Performance

USE WHEN: Cloning the template / pre-launch / Lighthouse audit

Cloudflare adapter ASSETS patch, deferred GTM loader, dual hero preload, 1-year cache headers, robots ASCII rule, _redirects status codes, Zaraz migration plan.

04 .claude/skills/carousel.md

Swiper Carousel

USE WHEN: Building any carousel / slider

Swiper.js implementation pattern. Imports only the modules in use. CLS-safe — reserves height before JS hydration.

05 .claude/skills/header-menu.md

Header Navigation

USE WHEN: Building or editing site navigation

Sticky header pattern, dropdown / flyout structure, active-state helpers (isActive / isSection), mobile drawer.

06 .claude/skills/googlereviews-carousel.md

Google Reviews Carousel

USE WHEN: Adding the global pre-footer reviews section / updating reviews per client

Free, manual Trustindex-style pattern. Native Astro carousel (~600 bytes JS), auto-filtered to 4–5 stars, capped at 20, newest first. Dynamic LocalBusiness + AggregateRating + Review[] JSON-LD. Optional Google Places API auto-fetch.

How to invoke a skill: say something like "use the contactform skill" or "build the hero with the hero-video pattern". Claude reads the skill file and follows it exactly.

20
Performance & Build Tooling

The site-wide setup that ships every clone with PageSpeed-friendly defaults.

Performance

Plain English: Every clinic site has the same speed problems by default — slow images, heavy tracking scripts, deploy errors. The template solves all of them up front so each new client starts at 90+ on PageSpeed without bespoke work.

1. Cloudflare adapter patch (deploy-blocker fix)

Cloudflare Pages refuses to deploy if any binding is named ASSETS (it's reserved). The Astro adapter hardcodes that name in 5 files. Our patch script renames every occurrence to STATIC_ASSETS automatically after every install.

scripts/patch-cloudflare-adapter.mjs — wired to npm postinstall. Idempotent (safe to re-run).

2. Image optimisation

  • npm run resize-images — resizes any oversized .webp to a max dimension (default 1920px), in-place re-encode at quality 82 using sharp.
  • Dual hero preload — BaseLayout accepts both heroImage (desktop) and heroImageMobile. Each device only fetches the variant it paints.
  • All images .webp — with explicit width/height, decoding="async", lazy by default, eager only for above-the-fold heroes.

3. Cache headers (public/_headers)

Cloudflare Pages auto-purges on every deploy, so 1-year caches are safe even on non-content-hashed paths.

/_astro/* 1 year, immutable
/images/* 1 year + stale-while-revalidate
/videos/* 1 year + stale-while-revalidate
/fonts/* 1 year, immutable
/icons/* 1 year, immutable
/*.svg 7 days

4. Deferred GTM loader

GTM saves 150–220 KB of JS by loading on idle, first user interaction, or 6-second fallback (Safari). Final move post-launch: migrate to Cloudflare Zaraz (zero browser JS).

5. Site-wide rules

  • <html lang="en-GB"> — UK targeting, British screen-reader voice, British spellcheck.
  • noindex default = true — template ships in noindex mode. Flip to false ONLY when the user says "go live".
  • robots.txt is ASCII-only — Cloudflare serves it without charset; box-drawing characters cause mojibake.
  • _redirects status codes — only 200/301/302/303/307/308 work on Pages. 404/410 need a Pages Function.
  • CSP includes media-src 'self' — required for self-hosted hero video.

6. Core Web Vitals targets

LCP

< 2.5s

Largest Contentful Paint (hero image load)

CLS

< 0.1

Layout Shift (no jumping content)

INP

< 200ms

Interaction responsiveness

PageSpeed mobile target: 90+. Below that on a fresh clone with no client-specific tuning means something has regressed in the template.

21
Hero Video Pattern

Self-hosted MP4 H.264. Mandatory across every HMDG clinic site.

MANDATORY

The Rule (no exceptions without sign-off):

  • ✓ Self-hosted MP4 H.264
  • ✗ NEVER YouTube embed
  • ✗ NEVER Vimeo embed
  • ✗ NEVER WebM-only (Safari needs MP4)

Why

YouTube embed

~1500ms CPU + ~1MB third-party JS. PageSpeed kills you.

Self-hosted MP4 H.264

~720KB single file + 0ms third-party CPU. Safari uses OS hardware decoder.

Note: Safari has supported MP4 natively since 2007. WebM is for Chrome/Firefox optimisation, NOT a Safari fallback. If you ship one format, ship MP4.

File layout

  • assets/videos/herovideo.mp4 — SOURCE master. Outside public/, never ships to production.
  • public/videos/hero-loop.mp4 — ENCODED output. Committed and deployed (~700KB–1.5MB).
  • public/images/herofallback.webp — POSTER. Always required, shown to mobile + reduced-motion users.

Step-by-step

1

Drop the source video

Save the source video to assets/videos/herovideo.mp4. Any container/codec is fine — the script handles conversion.

2

Encode it

Run npm run optimise-video. Output lands at public/videos/hero-loop.mp4 — capped at 1280×720, 10 seconds, audio stripped, optimised for streaming.

3

Use it on a page

Pass image (poster) and video (encoded MP4) props to <Hero />. Component handles the JS-injected source pattern automatically.

4

Verify

Open in Safari + Chrome desktop (plays). Open in mobile (poster only — no video bytes). Toggle "Reduce motion" (poster only).

Encode spec (non-negotiable)

-crf 28              quality target
-preset slow         better compression
-pix_fmt yuv420p     SAFARI MUST HAVE THIS
-movflags +faststart streaming-friendly
-an                  strip audio
-t 10                trim to 10 seconds
-vf scale=':min(1280,iw):-2'   cap width at 1280px

Full skill reference: .claude/skills/hero-video.md

22
Project Log Protocol

A simple internal log so every Claude session can pick up where the last one left off.

Workflow

Plain English: Imagine a notebook on the project where every working day someone writes "here's what I did, here's what's still open, here's what to do next." That's the project log. Claude reads it before starting and updates it before finishing. No more cold-starts.

Where

.project-log.md at the project root. Committed to GitHub. Never deployed — sits outside src/ and public/, so neither Astro nor Cloudflare Pages can serve it.

Rules

  1. 01

    Read first

    Before starting any task, read the log. Current Priorities + Unfinished + Blocked describe the running state.

  2. 02

    Update last

    Before declaring complete (or ending the session), append a Change Log entry and reconcile the Unfinished list.

  3. 03

    Always write down unfinished work

    If a task isn't fully shipped (uncommitted code, missing assets, follow-ups) it MUST live in Unfinished / To Do with enough detail to resume cold.

  4. 04

    Every entry has 5 fields

    Date · Status (completed / in progress / blocked / to do) · Task summary · Files changed · Next action. Notes optional.

  5. 05

    Keep it lightweight

    Short factual entries. No essays, no screenshots, no decoration.

What's in the log

Current Priorities

What we're focused on right now

Unfinished / To Do

Anything not fully shipped — must live here, not in someone's head

Blocked

Anything waiting on external info or decisions

Change Log

Dated entries — what changed, what files, what's next

Session Notes

Useful context, gotchas, decisions that affect later work

How is this different from CHANGELOG.md? CHANGELOG.md is the public semver release history of the template. .project-log.md is the internal working notebook. CHANGELOG ships with the template; .project-log.md stays internal.

23
Environment Variables

12 variables that drive every deploy. Most come from the marketing team.

Config

Plain English: Environment variables are settings that change per client without changing the code. Things like the clinic name, the contact email, the analytics ID. They live in a file called .env locally and in Cloudflare Pages settings on production. The template ships with .env.example showing every variable and what it's for.

Glossary

PUBLIC_ prefix = Astro convention. Variables starting with PUBLIC_ are baked into the HTML and visible in browser. Use only for values that are designed to be public (GA4 ID, reCAPTCHA site key). Secret values (Resend API key, reCAPTCHA secret) MUST stay without a PUBLIC_ prefix — server-side only.

Table 1 — Browser-exposed (PUBLIC_*)

Baked at build time. Visible in HTML source. Cloudflare needs a redeploy after changing these.

Variable Source Purpose
PUBLIC_SITE_NAME Marketing Email subject + page titles. e.g. "Smith Clinic"
PUBLIC_SITE_URL Dev Optional canonical URL fallback. Prefer site: in astro.config.mjs.
PUBLIC_GA_MEASUREMENT_ID Marketing GA4 measurement ID, e.g. G-XXXXXXXXXX
PUBLIC_GOOGLE_MAPS_API_KEY Marketing Google Maps embed (optional)
PUBLIC_CLINIKO_BOOKING_URL Marketing Cliniko booking widget URL (optional)
PUBLIC_RECAPTCHA_SITE_KEY Marketing reCAPTCHA v3 Site Key (browser). Secret Key is server-side (RECAPTCHA_SECRET_KEY).

Table 2 — Server-side (no PUBLIC_ prefix)

Never exposed to the browser. Used by Cloudflare Pages Functions (API routes) only.

Variable Source Purpose
RESEND_API_KEY Dev Resend secret key (re_...). Powers /api/send-email. Never exposed.
RESEND_EMAIL_RECEIVER Marketing Contact-form recipient inbox(es). COMMA-separated for multiple.
RESEND_EMAIL_SENDER Dev Sender "Name <noreply@domain>". Domain must be verified in Resend.
RECAPTCHA_SECRET_KEY Marketing reCAPTCHA v3 secret. /api/send-email verifies each token with Google.
GA4_API_SECRET Marketing Measurement Protocol API secret. GA4 → Admin → Data Streams.
GA4_MEASUREMENT_ID Marketing Server-side GA4 ID. Should match PUBLIC_GA_MEASUREMENT_ID.
SITE_ORIGIN Dev Production URL. Used by API routes to validate request origin.

How to set them — step by step

1

Local development

Copy .env.example to .env in the project root. Fill in real values. NEVER commit .env (it's git-ignored).

2

Production — Cloudflare Pages

Cloudflare dashboard → Workers & Pages → your project → Settings → Variables and Secrets. Add every variable. PUBLIC_* bake at build time; secrets (RESEND_API_KEY, RECAPTCHA_SECRET_KEY, RESEND_EMAIL_*) are read by the Functions at request time — set those as Secret (encrypted).

3

Trigger a redeploy

Cloudflare does NOT auto-rebuild when env vars change. Go to Deployments → latest deployment → Retry deployment. Without this, the site keeps using the old build's values.

4

Verify it worked

After redeploy, submit the contact form and confirm the email arrives. Secrets must NOT appear in view-source — only PUBLIC_ values do.

Critical notes

  • RESEND_EMAIL_RECEIVER uses commas. Example: client@x.co.uk, agency@hmdg.co.uk — the endpoint splits on commas and trims spaces into the recipient array.
  • reCAPTCHA Secret Key IS a server-side env var now. RECAPTCHA_SECRET_KEY (no PUBLIC_ prefix) — /api/send-email verifies each token with Google (success + score ≥ 0.5). Site Key stays public (PUBLIC_RECAPTCHA_SITE_KEY).
  • PUBLIC_ vars are build-time, not runtime. Editing them in Cloudflare without redeploying changes nothing. Always Retry deployment.
  • Don't put .env in src/, public/, or anywhere it could be served. The default location (project root) is correct.
Claude AI Prompt — Set Up Env Vars

Set up environment variables for [CLIENT NAME]. Copy .env.example to .env locally and fill in real values (PUBLIC_SITE_NAME, PUBLIC_SITE_URL, PUBLIC_GA_MEASUREMENT_ID, PUBLIC_GOOGLE_MAPS_API_KEY, PUBLIC_CLINIKO_BOOKING_URL, PUBLIC_RECAPTCHA_SITE_KEY, RESEND_API_KEY, RESEND_EMAIL_RECEIVER with commas, RESEND_EMAIL_SENDER, RECAPTCHA_SECRET_KEY, GA4_API_SECRET, GA4_MEASUREMENT_ID, SITE_ORIGIN). Then add the same values to Cloudflare Pages → Settings → Variables and Secrets — mark RESEND_API_KEY and RECAPTCHA_SECRET_KEY as Secret. Click Retry deployment when done. Verify by submitting the contact form and confirming the email arrives; secrets must never appear in view-source.

24
BaseLayout.astro

The single shared wrapper for every client-facing page.

Layout

Props

title string required

Page title tag content. Required on every page.

description string?

Meta description for SEO. Defaults to a generic clinic description if omitted.

noindex boolean?

Set true on thank-you and booking confirmation pages. Injects a noindex, nofollow robots meta tag in the page head.

Included Automatically — Do Not Re-Import

global.css

Once globally — never import again per-page

Google Fonts

Inter + Inter Tight, weights 400–800

Security meta tags

CSP, X-Frame, referrer — plus _headers / vercel.json

Consent Mode v2

Fires before GTM — required for GDPR compliance

GTM noscript

Fallback tag — only active when gtmId is set in config

Header component

Global — never add Header inside a page

Footer component

Global — never add Footer inside a page

CookieConsent

Global — never add CookieConsent inside a page

Usage Pattern

---
import BaseLayout from '../layouts/BaseLayout.astro';
---

<BaseLayout
  title="Services — ClinicName"
  description="Expert physiotherapy in London. Book online today."
>
  <section class="bg-[--color-muted]">
    <div class="container-main">
      <div class="container-inner">
        ...page content...
      </div>
    </div>
  </section>
</BaseLayout>

/* For thank-you and booking confirmation pages — add noindex */
<BaseLayout title="Thank You" description="..." noindex={true}>
Claude AI Prompt — BaseLayout

Build a new page at [URL]. Use BaseLayout from src/layouts/BaseLayout.astro. Pass title and description props. Never import global.css, Header, Footer, or CookieConsent separately — BaseLayout handles all of these. Content goes directly inside the BaseLayout tags. If this is a thank-you or confirmation page, add noindex prop set to true on the BaseLayout.

25
Cookie Consent Config

Tracking IDs are env-driven. Marketing team manages banner copy + policy version in src/config/cookie-consent.config.ts.

Config

Plain English: Tracking IDs (GTM, GA4, Google Ads) used to live as hardcoded strings in the config file. They now live in .env and are read at build time. The config file still controls banner copy, policy version, and the booking platform list — but never tracking IDs again.

Go-live blocker — these env vars MUST be set:

  • PUBLIC_GTM_ID OR PUBLIC_GTAG_ID — at least one (GTM is preferred when both modes are available)
  • GA4_MEASUREMENT_ID + GA4_API_SECRET — both required for server-side booking events
  • PUBLIC_SITE_NAME — banner copy reads At ${PUBLIC_SITE_NAME}, we use cookies…
  • SITE_ORIGIN — API endpoints reject requests from any other origin
  • ✗ NEVER hardcode tracking IDs in cookie-consent.config.ts again
  • ✗ NEVER commit a real .env to git (it's gitignored — keep it that way)

Required for go-live

Set every variable below in .env locally AND in Cloudflare Pages → Settings → Variables → Build scope. Click "Retry deployment" after saving.

PUBLIC_GTM_ID Required GTM-XXXXXXX

Google Tag Manager container ID. Preferred mode (GTM-only). Find: GTM → Admin → Container Settings.

PUBLIC_GTAG_ID Required G-XXXXXXXXXX

GA4 Measurement ID for direct gtag.js mode (no GTM). Use ONLY if PUBLIC_GTM_ID is blank. Find: GA4 → Admin → Data Streams → Web stream.

PUBLIC_GA_MEASUREMENT_ID Required G-XXXXXXXXXX

Browser-side GA4 ID — legacy fallback for components that read it directly. Match it to PUBLIC_GTAG_ID.

GA4_MEASUREMENT_ID Required G-XXXXXXXXXX

Server-side GA4 ID. Used by /api/book-now + /api/booking-complete to forward Measurement Protocol events. NEVER use the PUBLIC_ prefix here.

GA4_API_SECRET Required your-api-secret

GA4 Measurement Protocol API Secret. Find: GA4 → Admin → Data Streams → select stream → Measurement Protocol API secrets. Server-side only — never PUBLIC_.

PUBLIC_SITE_NAME Required Your Clinic Name

Drives the cookie banner copy ("At {clinic}, we use cookies…"), email from_name, page titles, JSON-LD.

SITE_ORIGIN Required https://yourclinic.co.uk

Production URL with https:// and no trailing slash. API routes reject requests whose Origin header doesn't match.

Optional

Site works without these — leave blank to disable the named feature or accept the default.

PUBLIC_ADS_CONVERSION_ID Optional AW-XXXXXXXXX

Google Ads Conversion ID — only needed for Google Ads remarketing / conversion tracking. Find: Google Ads → Tools → Tag Settings.

PUBLIC_SERVER_SIDE_TRACKING Optional true | false

Master switch. Set 'false' to disable /api/book-now + /api/booking-complete (e.g. staging or privacy-mode). Default: true.

PUBLIC_BOOKING_DISABLE Optional acuity,timely

Disable specific booking platforms (comma-separated, lowercase keys: acuity, calendly, cliniko, jane, phorest, pracsuite, simplybook, timely, youcanbook). Default: ALL enabled.

PUBLIC_BOOKING_ADDITIONAL_DOMAINS Optional mybooking.co.uk

Add custom booking domains not in the catalog (comma-separated). Example: white-label Cliniko subdomain.

Marketing team — edit in cookie-consent.config.ts

Tracking IDs are env-driven (above). The fields below remain in the config file because they are content, not credentials.

policyVersion '1.0'

Start every new site at '1.0'. Bumping this forces ALL existing visitors to re-consent. Only change when the cookie policy content actually changes.

bannerTitle 'We use cookies'

Heading shown in the cookie consent banner.

bannerText auto from PUBLIC_SITE_NAME

Body text in the banner. Auto-renders "At {PUBLIC_SITE_NAME}, we use cookies…" — only edit if the client wants different copy.

acceptAllLabel 'Accept All'

Primary accept button label. UK English spelling required.

rejectAllLabel 'Reject All'

Reject button label. UK English spelling required.

customiseLabel 'Customise'

Settings button label. Note UK spelling — Customise not Customize.

Config file — env-driven shape

// src/config/cookie-consent.config.ts
const ENV_GTM_ID            = import.meta.env.PUBLIC_GTM_ID            || '';
const ENV_GTAG_ID           = import.meta.env.PUBLIC_GTAG_ID           || '';
const ENV_ADS_CONVERSION_ID = import.meta.env.PUBLIC_ADS_CONVERSION_ID || '';
const ENV_SERVER_SIDE       = (import.meta.env.PUBLIC_SERVER_SIDE_TRACKING || 'true').toLowerCase() !== 'false';
const ENV_SITE_NAME         = import.meta.env.PUBLIC_SITE_NAME || 'this clinic';

export const cookieConsentConfig = {
  gtmId:              ENV_GTM_ID,             // ← env-driven
  gtagId:             ENV_GTAG_ID,            // ← env-driven
  adsConversionId:    ENV_ADS_CONVERSION_ID,  // ← env-driven (optional)
  serverSideTracking: ENV_SERVER_SIDE,        // ← env-driven (default true)
  policyVersion:      '1.0',                  // ← bump only when policy changes
  bannerTitle:        'We use cookies',
  bannerText:         `At ${ENV_SITE_NAME}, we use cookies…`,
  acceptAllLabel:     'Accept All',
  rejectAllLabel:     'Reject All',
  customiseLabel:     'Customise',
  // ...booking platforms (env-overridable)
};

Important rules

  • Tracking IDs (PUBLIC_GTM_ID, PUBLIC_GTAG_ID, GA4_*) live in .env only — never hardcoded in the config file.
  • PUBLIC_* vars are baked at build time. Cloudflare Pages requires a Retry deployment after changing them.
  • Only bump policyVersion when the cookie policy page text actually changes — not on every deploy.
  • All button labels must use UK English spelling (Customise, not Customize).
  • The bannerText must reflect what the live /cookie-policy page says.

Go-live checklist

1

Fill in .env locally

Copy .env.example to .env. Set every REQUIRED variable above. Save the file.

2

Mirror to Cloudflare

Cloudflare Pages → Settings → Variables and Secrets → BUILD scope. Add every same key + value. Plaintext type. Save.

3

Retry deployment

Cloudflare → Deployments → latest → Retry deployment. Without this step PUBLIC_* vars stay on the previous build.

4

Verify in view-source

Open the live site, view-source, search for the GTM/GA4 IDs you set. If you see them rendered in the HTML, tracking is wired.

5

Validate consent flow

Click Reject All → confirm no GA4 / Ads cookies set. Click Accept All → confirm consent_state cookie + GTM dataLayer firing. Re-test in incognito.

Claude AI Prompt — Cookie Consent Setup

Set up cookie consent + analytics for [CLIENT NAME] for go-live. Add the REQUIRED env vars to .env locally AND to Cloudflare Pages → Settings → Variables → Build scope: PUBLIC_GTM_ID (or PUBLIC_GTAG_ID if no GTM), PUBLIC_GA_MEASUREMENT_ID, GA4_MEASUREMENT_ID, GA4_API_SECRET, PUBLIC_SITE_NAME, SITE_ORIGIN. Do NOT hardcode tracking IDs in src/config/cookie-consent.config.ts — that file is env-driven. In the config file, only edit policyVersion (start at '1.0'), bannerTitle, bannerText, and the UK English button labels. Click Retry deployment in Cloudflare after saving env vars. Verify by view-sourcing the live site and grepping for the rendered GTM/GA4 IDs.

27
Google Reviews Carousel

Free, manual Trustindex-style pattern. Global pre-footer trust section.

Global

Plain English: Every clinic site has a Google Reviews carousel that sits between the booking CTA and the footer. It is loaded once globally via BaseLayout.astro — you do not add it per page. Reviews are pasted manually into one TypeScript file. This is 100% free forever — no Trustindex account, no widget, no monthly fee.

The Rule (no exceptions):

  • ✓ 100% free — no Trustindex, no EmbedSocial, no paid widget
  • ✓ Native Astro carousel — zero third-party JS, zero iframes
  • ✓ Auto-filtered ≥4 stars · capped at 20 · sorted newest first
  • ✓ Dynamic JSON-LD schema — built from the same data the carousel renders
  • ✗ NEVER fake or AI-generated reviews (UK ASA + Google ToS)
  • ✗ NEVER pay for a Google Reviews widget

Why this beats a paid widget

Trustindex / EmbedSocial widget

Iframe embed, third-party CSS + JS (~150–250 KB), -10 to -20 PSI mobile, vendor lock-in, paid tiers from ~£9/month for full features.

This pattern (HMDG)

Static HTML, ~600 bytes deferred JS, 0–1 PSI delta, fully customisable, free forever, no signup. Same Trustindex behaviour without the middleman.

File layout

  • src/data/reviews.ts — THE ONE FILE YOU EDIT. Reviews array + meta (average rating, total count, Maps URL).
  • src/components/ReviewsSection.astro — Component (markup, ~600 byte deferred JS, CSS, dynamic JSON-LD). Do not edit per client.
  • scripts/fetch-google-reviews.mjs — OPTIONAL Google Places API fetcher. Only used if a clinic wants auto-refresh.
  • .github/workflows/refresh-reviews.yml.example — OPTIONAL weekly GitHub Actions cron. Rename to .yml to activate.
  • .claude/skills/googlereviews-carousel.md — Full skill reference. Read this for complete pattern docs.

Step-by-step — Manual update (the recommended path)

No API keys. No Google Cloud account. No automation. Just paste real reviews into a TypeScript array. This is what every HMDG clinic site uses by default.

1

Open the data file

Open src/data/reviews.ts. You will see a reviewsRaw array near the top with placeholder reviews. This is the only file you edit.

2

Visit the clinic's Google Reviews page

Go to the client's Google Maps profile (NOT Google Business Profile dashboard — the public Maps page). Click "Reviews" tab. Pick 8–15 of the best 4–5 star reviews from the last 6 months.

3

Paste each review into the array

Each entry needs five fields: name (string), rating (4 or 5), text (string — the review verbatim, do not edit it), relativeTime (e.g. "2 weeks ago"), date (ISO format YYYY-MM-DD — for sorting). Source is always 'google'.

4

Update the meta block

Update fallbackMeta with the clinic's real numbers: averageRating (e.g. 4.9), totalReviews (e.g. 247), totalReviewsDisplay (e.g. "200+"), and mapsUrl (the public Google Maps page for the clinic — used by the "See all reviews on Google" link).

5

Commit + push

Cloudflare Pages rebuilds automatically. The site filters to ≥4 stars, sorts newest first, caps at 20, and re-emits the JSON-LD schema — all from your edits to that one file.

Review object shape (paste this pattern, change the values)

{
  name:         'Sarah Mitchell',
  rating:       5,
  text:         "Lower back pain gone after 6 sessions. Genuinely life-changing care.",
  relativeTime: '2 weeks ago',
  date:         '2026-04-18',
  source:       'google',
},

The display logic at the bottom of reviews.ts filters ≥4 stars, sorts by date descending, and caps at 20. So you can safely paste 30+ reviews — only the freshest qualifying ones display.

Dynamic JSON-LD schema (built-in, automatic)

The component emits Schema.org structured data on every page — built dynamically from the same reviews + reviewsMeta exports that render the visible HTML. When you update reviews, the schema updates automatically. No manual maintenance, no second source of truth, no risk of schema drifting from what the page shows.

LocalBusiness

Name, URL, @id anchor — declares the entity reviews belong to.

AggregateRating

ratingValue + reviewCount from reviewsMeta — auto-formatted to 1 decimal.

Review[]

Author, rating, datePublished, reviewBody — one entry per visible review.

// ReviewsSection.astro frontmatter
const reviewsLd = {
  '@context': 'https://schema.org',
  '@type':    'LocalBusiness',
  '@id':      `${siteUrl.replace(/\/$/, '')}/#localbusiness`,
  name:       siteName,
  url:        siteUrl,
  aggregateRating: {
    '@type':     'AggregateRating',
    ratingValue: Number(reviewsMeta.averageRating.toFixed(1)),
    reviewCount: reviewsMeta.totalReviews,
    bestRating:  5,
    worstRating: 1,
  },
  review: reviews.map(r => ({
    '@type': 'Review',
    author: { '@type': 'Person', name: r.name },
    reviewRating: {
      '@type':     'Rating',
      ratingValue: r.rating,
      bestRating:  5,
      worstRating: 1,
    },
    datePublished: r.date,
    reviewBody:    r.text,
  })),
};

Realistic SEO impact

  • ✓ Reinforces local SEO trust signals (Google Knowledge Graph, AI search citations)
  • ✓ Eligible for rich results when Google's policy on self-serving review stars shifts
  • ✓ Zero downside — valid Schema.org, no penalty, no maintenance
  • ✗ Will NOT magically rank you higher (schema is metadata, not a ranking factor)
  • ✗ Star ratings will NOT appear in Google SERP (suppressed by 2019 self-serving policy)

Validate after each clinic launch via https://search.google.com/test/rich-results.

Per-client customisation points

MAX_REVIEWS src/data/reviews.ts

Cap on reviews shown. Default 20.

MIN_RATING src/data/reviews.ts

Filter floor. Default 4 — drops 1–3 star noise.

<h2 id="reviews-headline"> ReviewsSection.astro

Headline copy — default "Trusted by Cheltenham Patients on Google".

mapsUrl fallbackMeta in reviews.ts

Public Google Maps URL — powers the "See all reviews" link.

Optional — auto-fetch from Google Places API

For clinics that want weekly auto-refresh: add GOOGLE_PLACES_API_KEY + GOOGLE_PLACE_URL to .env, run npm run reviews:fetch, commit src/data/reviews-cache.json. Optionally rename refresh-reviews.yml.example.yml for a Monday-06:00-UTC cron.

Cost: free in practice. Google Cloud's $200/month credit covers ~10 000 Place Details calls; weekly refresh = ~5 calls/month ≈ £0.07. Place API returns max 5 reviews per call, so most clinics combine auto-fetch (5 fresh) with manual paste (up to 15 older keepers).

Full setup: .claude/skills/googlereviews-carousel.md

When cloning to a new client

  • Replace the reviewsRaw array in src/data/reviews.ts with the new clinic's real Google reviews (8–15 entries, all 4–5 star).

  • Update fallbackMeta — averageRating, totalReviews, totalReviewsDisplay, and mapsUrl.

  • Update the H2 in ReviewsSection.astro — change "Cheltenham" to the new clinic's location.

  • Update PUBLIC_SITE_NAME + Astro.site (astro.config.mjs) — these power the JSON-LD schema's name/url fields.

  • Validate after deploy at https://search.google.com/test/rich-results.

Claude AI Prompt — Update Google Reviews

Update the Google Reviews carousel for this clinic. Edit only src/data/reviews.ts. Replace the reviewsRaw array with [N] real Google reviews (paste each one verbatim — do not edit text, do not invent reviews). Each entry needs name, rating (4 or 5 only), text, relativeTime, date (YYYY-MM-DD), source: 'google'. Update fallbackMeta with the clinic's averageRating, totalReviews, totalReviewsDisplay, and mapsUrl (the public Google Maps page URL). Do not edit ReviewsSection.astro unless the H2 location keyword needs to change. Do not install Trustindex or any paid widget. Verify the JSON-LD schema renders correctly on the homepage view-source.

28
Client Setup Checklist

Two phases — what marketing must give you, then what dev does.

Setup

Plain English: A clinic site needs information that ONLY the client / marketing team can give you (brand colours, analytics IDs, contact email, real address). Phase 1 is the briefing list to send them. Phase 2 is the build steps — start once Phase 1 is in your hands.

Phase 1 — Required from Marketing Team

Do not start dev work until every item below is in your hands. If any are missing, send the briefing prompt at the bottom of this section.

M1. Brand

  • Primary brand colour (hex code, e.g. #DB003E)

  • Hover/darker shade of primary (hex)

  • Light accent tint (hex) — for section backgrounds

  • Logo files — .svg preferred, plus .png at 2× resolution

  • Favicon (.svg)

  • Confirm Inter Tight / Inter typography or specify alternative

M2. Cookie Consent & Analytics

  • GTM (Google Tag Manager) container ID, e.g. GTM-XXXXXXX — OR confirm we go straight to Cloudflare Zaraz

  • GA4 Measurement ID, e.g. G-XXXXXXXXXX (used twice — PUBLIC_ for browser + non-PUBLIC for server)

  • GA4 Measurement Protocol API Secret (GA4 → Admin → Data Streams → API secrets)

  • Banner copy (the consent banner text shown to visitors)

  • Cookie Policy URL + policy version number (e.g. v1.0)

M3. Contact Form (Resend)

  • Resend account (resend.com) — API key with Sending access, OR permission to create one.

  • Verified sending domain in Resend (Domains → Add Domain → add the DNS records shown).

  • Recipient inbox(es) — the email(s) that should receive every submission, comma-separated. Example: client@x.co.uk, agency@hmdg.co.uk

  • Sender address on the verified domain — e.g. Contact Form <noreply@clinic.co.uk>

  • Google reCAPTCHA v3 Site Key AND Secret Key (from https://www.google.com/recaptcha/admin). Both go in env vars — Secret as RECAPTCHA_SECRET_KEY (server-side).

M4. NAP & Structured Data

Used in the contact page, JSON-LD structured data, and footer.

  • Full clinic name (exactly how it appears on Google Business Profile)

  • Phone number (display format AND tel: format, e.g. "+44 (0) 1234 567 890" + "+441234567890")

  • Email address (the public contact email)

  • Address line 1 (street)

  • Address line 2 (town + postcode, e.g. "London, SW1A 1AA")

  • Google Maps URL for the clinic

  • Opening hours per day (e.g. Mon–Fri 09:00–17:00, Sat closed, Sun closed)

M5. Visual Assets

  • Hero image — 1920×800 .webp (desktop). Required.

  • Hero image mobile — 800×800 .webp (optional, for the dual-preload pattern)

  • Social/OG image — 1200×630 .webp (used by Facebook/LinkedIn/iMessage previews)

  • Optional hero video source — ≤30s, 1080p+, any container/codec. Will be encoded to MP4 H.264 by us.

  • Service / treatment imagery (per page)

  • Team photos (if a Team page is included)

M6. Legal Page Content

  • Privacy Policy — full text

  • Terms & Conditions — full text

  • Cookie Policy — full text (must match the consent banner copy from M2)

Claude AI Prompt — Marketing Briefing (copy + send to client)

Hi [CLIENT NAME] — to start your new clinic website we need the following from your marketing team. Most items are quick to gather. Send everything in one email or shared doc and we'll begin the build the same day. (1) BRAND: primary brand hex, hover hex, light accent hex, logo SVG and PNG, favicon, preferred fonts. (2) ANALYTICS: GTM container ID, GA4 Measurement ID, GA4 Measurement Protocol API Secret, consent banner copy, cookie policy URL and version. (3) CONTACT FORM: Resend account or permission to create one, a sending domain we can verify in Resend (we'll send you DNS records), recipient inbox(es) (comma-separated for multiple), Google reCAPTCHA v3 Site Key, Google reCAPTCHA v3 Secret Key. (4) BUSINESS: clinic legal name, phone (display + tel: format), public email, full address, Google Maps URL, opening hours per day. (5) ASSETS: hero image 1920×800 WebP, optional mobile hero 800×800, social image 1200×630, optional hero video source MP4. (6) LEGAL: Privacy Policy text, Terms & Conditions text, Cookie Policy text. We do not start dev work until all six sections are in our hands.

Phase 2 — Dev Build Steps

Run these steps in order. Each one builds on the previous.

D1. Brand colours & fonts · src/styles/global.css @theme

--color-primary

Set to client primary brand hex. Updates every button, link, and accent automatically.

--color-primary-hover

Slightly darker or lighter shade of --color-primary. Hover state for interactive elements.

--color-accent

Light tint of the brand colour. Section backgrounds + secondary fills.

--font-heading

Change if client uses a different heading font. Inter Tight is the default.

--font-body

Change if client uses a different body font. Inter is the default.

D2. Cookie consent · src/config/cookie-consent.config.ts

Set gtmId, gtagId, bannerText, and policyVersion. Full field reference in section 25.

D3. Global components

src/components/Header.astro

Update clinic name, logo, navigation links, phone, booking CTA.

src/components/Footer.astro

Update clinic name, address, map embed, social links, legal page links.

public/favicon.svg

Replace with the client's favicon. Keep .svg format.

D4. Legal pages & noindex rules

/privacy-policy

Add client content. Indexable at launch.

/terms-conditions

Add client content. Indexable at launch.

/cookie-policy

Add client content. Must match bannerText.

/thank-you

noindex={true} — already set in template.

/thank-you-booking

noindex={true} — already set in template.

/contact

Update per-client clinic data + JSON-LD hours.

D5. Resend wiring (NEW)

  1. 01

    Sign up / log in at resend.com. Add the client's sending domain (Domains → Add Domain) and add the DNS records it shows. Wait for verification.

  2. 02

    Create an API key (API Keys → Create — Sending access is enough). This goes in RESEND_API_KEY (secret).

  3. 03

    Set RESEND_EMAIL_RECEIVER to the recipient inbox(es), comma-separated for multiple.

  4. 04

    Set RESEND_EMAIL_SENDER to a from-address on the verified domain, e.g. Contact Form <noreply@clinic.co.uk>.

  5. 05

    Set PUBLIC_RECAPTCHA_SITE_KEY (public) and RECAPTCHA_SECRET_KEY (secret) from the reCAPTCHA admin — /api/send-email verifies tokens itself.

  6. 06

    Add all values locally and on Cloudflare Pages (see section 23); mark the two keys as Secret. Click Retry deployment.

D6. Performance assets (NEW)

  1. 01

    Drop the desktop hero at public/images/hero.webp (1920×800).

  2. 02

    Optional: drop public/images/hero-mobile.webp (800×800) for the dual-preload pattern.

  3. 03

    Drop public/og-image.webp (1200×630) for social sharing.

  4. 04

    Optional: drop assets/videos/herovideo.mp4 (source). Run npm run optimise-video to encode → public/videos/hero-loop.mp4.

  5. 05

    Run npm run resize-images to ensure no .webp exceeds 1920×1920.

D7. Production origin (NEW)

Set site: in astro.config.mjs to the production domain (e.g. 'https://yourclinic.co.uk'). This anchors canonical URLs, OG tags, and the auto-generated sitemap to the correct origin.

D8. Launch (NEW)

  1. 01

    Flip the noindex default in BaseLayout.astro from true → false. Permanent-noindex pages (thank-you, 404) keep their explicit noindex={true}.

  2. 02

    Run a fresh PageSpeed audit on the production URL. Target: 90+ mobile.

  3. 03

    Confirm the Resend sending domain is verified (Resend → Domains) and RESEND_EMAIL_SENDER uses it.

  4. 04

    Update Google reCAPTCHA admin → your site → Settings → add the production domain.

  5. 05

    Update public/robots.txt → Sitemap line → swap pages.dev URL for production domain.

D9. Post-launch (NEW)

Once the custom domain is on Cloudflare proxy (orange cloud), migrate GTM → Cloudflare Zaraz. Eliminates 150–220 KB of browser JS. Full plan in .claude/skills/performance.md Section 10.

API Routes Included

/api/book-now src/pages/api/book-now.ts

Booking form submissions (server-side).

/api/booking-complete src/pages/api/booking-complete.ts

Post-booking confirmation flow + GA4 Measurement Protocol redundancy.

Claude AI Prompt — Run Phase 2

Set up this template for [CLIENT NAME] using the Phase 2 dev steps. D1: update --color-primary, --color-primary-hover, --color-accent in src/styles/global.css. D2: set gtmId, gtagId, bannerText, policyVersion in src/config/cookie-consent.config.ts. D3: update Header.astro, Footer.astro, favicon.svg. D4: add legal page content. D5: wire Resend (RESEND_API_KEY, RESEND_EMAIL_RECEIVER, RESEND_EMAIL_SENDER, RECAPTCHA_SECRET_KEY + PUBLIC_RECAPTCHA_SITE_KEY, locally + Cloudflare with the two keys marked Secret, then Retry deployment). D6: drop hero.webp, hero-mobile.webp, og-image.webp, optionally encode hero video. D7: set site: in astro.config.mjs. D8: at launch, flip noindex default to false, PageSpeed 90+ mobile, confirm Resend domain verified, update reCAPTCHA admin domains, update robots.txt sitemap URL. D9: post-launch GTM → Zaraz migration.

29
Deploy to Cloudflare Pages

Step-by-step. From a fresh clone to a live URL on Cloudflare Workers / Pages.

Deploy

Plain English: Cloudflare Pages is the host. It builds the Astro site from your GitHub repo and serves it from edge servers worldwide. Free for clinic-scale traffic. The steps below take ~15 minutes the first time, <5 minutes for re-deploys.

Pre-flight check

  • GitHub repo exists with the project pushed to main/master.

  • Cloudflare account exists (free tier is fine).

  • All 12 env vars from section 23 are ready.

  • Local npm run build passes cleanly.

Step 1 — Connect the GitHub repo

  1. 01

    Sign in to Cloudflare dashboard at dash.cloudflare.com.

  2. 02

    Left sidebar → Workers & Pages.

  3. 03

    Click "Create application" → "Pages" tab → "Connect to Git".

  4. 04

    Authorise Cloudflare for the GitHub organisation that owns the repo (one-time grant).

  5. 05

    Select the repository from the list.

  6. 06

    Click "Begin setup".

Step 2 — Build settings

Cloudflare auto-detects most of these for Astro projects. Verify they match exactly:

Framework preset

Astro

Build command

npm run build

Build output

dist

Root directory

/

Node version

22.12.0 or higher

Production branch

main (or master)

Step 3 — Environment variables

Critical: choose BUILD scope, not Runtime.

PUBLIC_* vars are baked into the HTML at build time. Setting them as Runtime does nothing.

  1. 01

    Settings → Variables and Secrets → Add variable.

  2. 02

    Add each var from section 23. PUBLIC_* as Plaintext (baked at build); RESEND_API_KEY + RECAPTCHA_SECRET_KEY as Secret (encrypted, read by Functions at runtime).

  3. 03

    For RESEND_EMAIL_RECEIVER paste the comma-separated list directly — no escaping needed.

  4. 04

    Save each one as you go.

Step 4 — First deploy

  1. 01

    Click "Save and Deploy". Cloudflare runs npm install + npm run build.

  2. 02

    First build takes 2–4 minutes. Watch the live log.

  3. 03

    Postinstall hook auto-runs scripts/patch-cloudflare-adapter.mjs (renames ASSETS → STATIC_ASSETS).

  4. 04

    If you see error "The name 'ASSETS' is reserved" — the patch script didn't run. Check that scripts/ is committed and postinstall is in package.json.

  5. 05

    On success, you get a *.pages.dev preview URL (e.g. yourclinic.pages.dev).

  6. 06

    Open the preview URL and click around. Submit the contact form to verify Resend delivery.

Step 5 — Connect the custom domain

  1. 01

    Pages project → Custom domains → Set up a custom domain.

  2. 02

    Enter the domain (e.g. yourclinic.co.uk). Cloudflare gives DNS instructions.

  3. 03

    If the domain is registered with Cloudflare: nameservers route it automatically. If registered elsewhere: add a CNAME at the registrar pointing to your-project.pages.dev.

  4. 04

    Wait for DNS propagation (5–30 minutes). Cloudflare provisions the SSL cert automatically.

  5. 05

    Once connected, the domain shows green/active in the Pages dashboard.

Step 6 — Retry deployment after env-var changes

Cloudflare does NOT auto-rebuild when env vars change.

After saving any new env var, you MUST trigger a redeploy or the new value is ignored.

  1. 01

    Pages project → Deployments tab.

  2. 02

    Find the most recent successful deployment.

  3. 03

    Click the "..." menu → "Retry deployment".

  4. 04

    Cloudflare runs a fresh build with the current env vars baked in.

  5. 05

    When green, the new values are live.

Step 7 — Continuous deploys (automatic)

Once connected, every push to the production branch (main/master) triggers an automatic build + deploy. Pull request branches get preview deploys at [branch].your-project.pages.dev.

Troubleshooting

Build fails: "The name 'ASSETS' is reserved in Pages projects"

scripts/patch-cloudflare-adapter.mjs didn't run. Verify scripts/ is in the repo + package.json has "postinstall": "node scripts/patch-cloudflare-adapter.mjs".

Form submits but email subject shows "Website"

PUBLIC_SITE_NAME env var not set on Cloudflare, OR was set but didn't redeploy. Add it and Retry deployment.

Form returns 500 / email never arrives

Check RESEND_API_KEY, RESEND_EMAIL_SENDER (must use a domain verified in Resend), and RESEND_EMAIL_RECEIVER (comma-separated) are all set as Cloudflare secrets. Check the Resend dashboard logs.

reCAPTCHA fails on production but works locally

Production domain not in reCAPTCHA admin's allowed domains list. https://www.google.com/recaptcha/admin → your site → Settings → add the domain.

Custom domain stuck in "Provisioning"

DNS not propagated yet. Wait up to 30 minutes. If still stuck after an hour, verify the CNAME at the registrar.

404 on /thank-you/ even though the page exists

Check public/_headers — the X-Robots-Tag rules use exact paths. The Astro build emits /thank-you/index.html so /thank-you/ works but /thank-you may not. Test both.

30
Fallback Redirection

Rank Math "Fallback Behaviour" equivalent — unmatched URLs 301 to the homepage on Cloudflare. Real 404s still surface on astro dev.

Worker

Plain English: Ports the Rank Math "Fallback Behaviour → Redirect to Homepage" feature to Astro. Catches stray old URLs (forgotten WP permalinks, deep backlinks, crawler noise) at production and 301-redirects them to / instead of returning 404 — preserving link equity during a WP → Astro migration.

Behaviour

Environment Result for unmatched URL Why
astro dev (localhost) Real 404 — branded 404.astro renders Surfaces missing routes, broken internal links, and typos during development.
Cloudflare Pages (preview + production) 301/ SEO migration safety — old WP URLs flow to the homepage instead of breaking.

Mechanism — automatic gate, no manual toggle

// src/middleware.ts
const FALLBACK_REDIRECT_ENABLED = import.meta.env.PROD;
//                                ^^^^^^^^^^^^^^^^^^^
// false on `astro dev`, true on every `astro build` — no risk of
// forgetting to flip a manual switch on launch day.

Implementation

  • src/middleware.ts — intercepts 404 responses inside the Worker and rewrites to 301. Excludes /api/, asset prefixes, robots.txt, sitemaps, favicon, manifest.
  • public/_redirects — specific old → new mappings only. NEVER a wildcard (Cloudflare evaluates this before the Worker; a wildcard there loops the prerender simulator).
  • .claude/prompts/fallback-redirect.md — full architecture reference. Read for edge cases, exclusion rationale, and migration playbook.

Per-client customisation

  • Map known WP migration URLs by adding specific old → new lines to public/_redirects ABOVE the Rank Math comment block. The wildcard fallback in middleware only catches the long tail.

  • If the site uses public folders beyond /images/, /videos/, /fonts/, /icons/, /_astro/, add the prefix to NEVER_FALLBACK_PREFIXES in src/middleware.ts so genuine 404s on those assets still surface as bugs.

  • To test the fallback locally, run astro build + astro preview (uses PROD) instead of astro dev.