2026-08-02-001-feat-adventure-weddings-seo-foundation-plan

feat: SEO foundation and article port for the Adventure Weddings site

Target repo: adventure-weddings (GitHub veganpolice/adventure-weddings, local clone ~/Projects/adventure-weddings). This plan document lives in the sendy-adventures-admin monorepo, but every file path below is relative to the target repo, not to this one.


Goal Capsule

Make the live officiant site rankable for Sea to Sky ceremony search intent, and publish eleven already-written articles that currently sit unpublished in a deprecated codebase.

The site is server-rendered and already has a good metadata pattern on one route. The gaps are that every page shares one title, no blog post has a URL, there is no Markdown authoring path, and crawlers have no sitemap. Closing those four gaps and porting the articles — with the false claims removed — is the whole of this work.


Problem Frame

Adventure Weddings sells Lana’s officiant services in Squamish, Whistler, and Pemberton. Growth depends on being found at the moment couples search, and the site cannot currently compete:

  • One title for the whole site. __root.tsx, index.tsx, and services.tsx each set the identical title "Squamish Wedding Officiant | Lana, Rev. Rose — Sea to Sky & BC". blog, contact, how-it-works, and testimonials set none and inherit it. Search engines see one page repeated seven times.
  • No post has a URL. src/pages/Blog.tsx renders cards that open a client-side modal. The Post type carries a slug field that nothing routes on. There is no address for a search result to point at, and no way for a couple to share their own wedding story.
  • No authoring path for long-form content. Posts are a hardcoded TypeScript array with body: string[] — plain paragraphs, no headings or lists. The articles average ~1,500 words with heading structure and cannot be expressed in that shape.
  • No sitemap. public/robots.txt exists and allows everything, but names no sitemap.
  • The articles assert things that are not true. They were written against an aspirational positioning: Lana as in-house photographer, Aaron as a certified climbing guide, and $4,500–$14,500 packages. None of that is real, and republishing it would be both inaccurate and off-strategy.

Fourteen articles exist in the deprecated Astro app. Three are being dropped (see KTD5), leaving eleven to port.


Requirements

IDRequirement
R1Every indexable page serves a unique <title> and meta description.
R2Every blog post — article and couple recap alike — is reachable at a stable, unique URL under /blog/<slug>.
R3Long-form articles are authored as Markdown files in the repo; couple recaps stay in the existing TypeScript array so Lana can keep adding them through the Lovable UI.
R4No published page claims in-house photography or videography, guiding or climbing services, or the superseded $4,500–$14,500 pricing.
R5Crawlers can discover every indexable URL via sitemap.xml, referenced from robots.txt.
R6Post pages carry a canonical URL and BlogPosting structured data.
R7The blog index presents both post types coherently, with category filtering intact.
R8The work lands via a short-lived branch and PR with CI green, per the repo’s Lovable-sync workflow.

Key Technical Decisions

KTD1 — Load Markdown with Vite’s native import.meta.glob, not a Vite plugin. vite.config.ts carries an explicit warning that @lovable.dev/vite-tanstack-config already bundles the plugin set and that adding those plugins manually breaks the app with duplicates. import.meta.glob is built into Vite and needs no plugin registration, so it sidesteps the hazard entirely. Frontmatter parsing and Markdown rendering become ordinary runtime dependencies rather than build-pipeline changes. This is the single most important constraint-driven decision in the plan.

KTD2 — Hand-rolled sitemap server route rather than TanStack’s built-in generator. TanStack Start can emit a sitemap at build time via tanstackStart.sitemap config (available since 1.163.2; the repo is on 1.168.26). Rejected because it requires enabling prerendering, which changes the build pipeline that Lovable owns and deploys — precisely the risk KTD1 avoids. A server route at src/routes/sitemap[.]xml.ts returning new Response(xml, { headers }) keeps the build untouched and gives explicit control over which URLs are listed. The repo already uses TanStack’s [.] filename escaping in src/routes/[.]lovable.oauth.consent.tsx, so the convention is established locally.

KTD3 — One unified post model over two sources. Articles (Markdown) and recaps (TS array) normalize into a single Post shape in a shared library module. The index, the detail route, and the sitemap all read that one list. This keeps the two authoring paths from leaking into three separate consumers, and makes slug-collision detection a single check.

KTD4 — Retire the modal; every post gets a real page. Confirmed with the user. Unifies the index’s click behavior, makes the couple recaps rankable, and gives couples a shareable link to their own story — currently impossible. No redirects are needed because no post URL ever existed.

KTD5 — Drop three articles rather than rewrite them. elopement-photographer-officiant-bc.md and rock-climbing-elopement-squamish-guide.md are built entirely on the photographer-officiant combo and on Aaron running a guiding business. There is no honest version that keeps the title. The climbing article additionally advertises route selection, rope management, and multi-pitch safety without certification, which carries liability exposure the others do not.

squamish-elopement-cost-breakdown.md is dropped for a different reason: it carries three false claims and is structured around the retired $4,500–$14,500 tiers. A cost article whose entire frame is wrong pricing is a rewrite wearing a port’s clothing, and bundling that into this plan would make U7 unbounded. Dropping it keeps the port mechanical. This forfeits a high-intent keyword — cost and pricing queries sit close to the booking decision — so a fresh, accurate pricing article is listed under deferred follow-up work rather than abandoned.

All three confirmed dropped with the user.

KTD6 — Mirror the metadata pattern already in services.tsx. That route already does head: with meta, links: [{ rel: "canonical" }], and scripts: [{ type: "application/ld+json" }]. It is a correct, working local template. Follow it rather than inventing a second approach.


High-Level Technical Design

Two authoring paths converge on one model, which three consumers read:

flowchart TD
    MD["src/content/articles/*.md<br/>11 Markdown articles<br/>(Aaron authors)"]
    TS["recap array in src/lib/posts<br/>~9 couple recaps<br/>(Lana authors via Lovable UI)"]

    MD -->|"import.meta.glob + frontmatter parse"| MODEL
    TS -->|"direct"| MODEL

    MODEL["src/lib/posts.ts<br/>unified Post[] + getPostBySlug()<br/>slug uniqueness enforced"]

    MODEL --> IDX["/blog<br/>index: cards, category filter"]
    MODEL --> DETAIL["/blog/$slug<br/>detail page + canonical + BlogPosting"]
    MODEL --> MAP["/sitemap.xml<br/>static routes + every post URL"]

    IDX -->|"links to"| DETAIL

The two post types differ in one respect the model must preserve: articles carry rendered Markdown HTML, recaps carry an array of plain paragraphs. A discriminant field on the unified type lets the detail route render the right one without the index caring.


Output Structure

New files this plan introduces:

src/
  content/
    articles/            # 11 ported Markdown articles, slug = filename
  lib/
    posts.ts             # unified model, glob loading, frontmatter parse, lookup
    markdown.ts          # markdown -> HTML
    __tests__/
      posts.test.ts
  routes/
    blog.$slug.tsx       # post detail route
    sitemap[.]xml.ts     # sitemap server route
  pages/
    BlogPost.tsx         # post detail page component

Per-unit Files lists remain authoritative; this tree is the shape, not a constraint.


Implementation Units

U1. Repair CI dependency install

Goal: Make PR CI capable of passing before anything else is built on top of it.

Requirements: R8

Dependencies: none

Files: .github/workflows/ci.yml, possibly package-lock.json

Approach: Confirmed empirically on 2026-08-02 via PR #4 (the Search Console verification file). CI fails on every PR and every push to main, and has done since the repo moved to Bun. The exact error:

##[error]Dependencies lock file is not found in /home/runner/work/adventure-weddings/adventure-weddings.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock

It fails at actions/setup-node@v4, not at npm ci — the step never runs. The workflow sets cache: npm, and setup-node’s npm cache needs one of those three lockfiles to build a cache key. The repo has only bun.lock, which setup-node does not recognise. Every subsequent step (npm ci, lint, test, build) is skipped, so nothing in this repo is currently being linted, tested, or build-checked.

Note that removing cache: npm alone is not sufficient — it would get past setup-node only for npm ci to then fail on the same missing lockfile.

✅ RESOLVED — PR #5, merged-ready and CI green 2026-08-02. The Bun path this plan originally preferred turned out to be impossible, not merely less preferred.

bun.lock contains 654 baked-in URLs to Lovable’s private Artifact Registry (europe-west*-npm.pkg.dev/lovable-core-prod). bun install --frozen-lockfile 403s anywhere outside Lovable’s sandbox, and BUN_CONFIG_REGISTRY does not override URLs already pinned in a lockfile — both verified empirically. The lockfile is not portable, so CI cannot use Bun at all.

What shipped instead:

  • npm install --legacy-peer-deps --no-package-lock --no-audit --no-fund, no cache: npm. Every dependency including @lovable.dev/* is on public npm, so npm resolves the same tree. No committed package-lock.json — it would go stale whenever Lovable changes dependencies and fail as a confusing mismatch; resolving fresh costs ~2 min and cannot drift. CI is only a correctness gate, since Lovable builds and deploys the real artifact.
  • --legacy-peer-deps is required: next-themes@0.3.0 declares a React 16–18 peer while the app is on React 19. Bun tolerates this; npm rejects it.
  • prettier/prettier demoted to a warning. The first real run surfaced 1,727 errors of which 1,713 were formatting — code written through the Lovable UI does not match this prettier config, so as errors they would keep lint permanently red and bury real defects.
  • The 14 genuine errors were fixed: 11 no-var in supabase/functions/mcp/index.ts (auto-generated, banner says “do not edit” → added to eslint ignores) and 3 no-explicit-any in src/lib/oauth.ts (replaced supabase as any with a declared interface for the auth.oauth.* surface missing from the installed Supabase types).

Implication for every later unit: the test suite (16 tests, 2 files) passes and now actually runs. Later units gain real CI protection — but note ~1,676 formatting warnings remain as visible, ungated debt.

Verification: A PR against main completes lint, test, and build. Because the suite has never actually run in CI, expect the first green run to surface pre-existing lint or test failures that were previously invisible; budget for that rather than treating it as regression from this plan.

Test scenarios: Test expectation: none — CI configuration change with no application behavior. The workflow run itself is the verification.


U2. Unified post model and Markdown pipeline

Goal: One typed list of posts assembled from both authoring sources, with no UI change yet.

Requirements: R3, partially R2

Dependencies: U1

Files: src/lib/posts.ts (new), src/lib/markdown.ts (new), src/lib/__tests__/posts.test.ts (new), src/content/articles/.gitkeep (new), package.json

Approach: Load articles with import.meta.glob('../content/articles/*.md', { query: '?raw', import: 'default', eager: true }) — Vite-native, no plugin (KTD1). Note that Vite requires a literal relative or root-absolute pattern here; the @/ alias is not reliably supported inside a glob pattern. Parse YAML frontmatter and render Markdown to HTML. Add two small runtime dependencies for this; do not add a Vite plugin.

Dependency choice matters more than usual here. The obvious frontmatter library, gray-matter, depends on Node’s Buffer, which Vite does not polyfill for the browser. If any parsing reaches the client bundle it throws Buffer is not defined, and the standard remedy — vite-plugin-node-polyfills — is a new Vite plugin, which KTD1 forbids. So the usual escape hatch is closed in this repo. Prefer a Buffer-free frontmatter parser (a pure-JS one such as front-matter, or js-yaml against a hand-split frontmatter block). For rendering, pick a dependency-light pure-JS Markdown library. If gray-matter is chosen anyway, parsing must be provably server-only — see Open Questions.

The source frontmatter is already consistent across all eleven files: title, description, pubDate, author, tags. Adopt that shape as the contract so the port needs no frontmatter rewriting.

Normalize both sources into one discriminated Post type: shared fields (slug, title, category, excerpt, image, publishedAt) plus a kind discriminant carrying either rendered html (articles) or body: string[] (recaps). Move the existing recap array out of src/pages/Blog.tsx into this module, preserving its contents and the "Real Weddings" | "Squamish Spots" categories exactly — Lana edits that array through Lovable, so its shape must stay recognizable. Add a third category for articles (e.g. "Guides"). Note that the array depends on roughly seventeen @/assets/*.asset.json imports at the top of Blog.tsx; those move with it, and Blog.tsx should end up importing posts rather than assets.

Make image optional on the type — ported articles have none (see Scope Boundaries).

Export a sorted list plus getPostBySlug. Enforce slug uniqueness across both sources. Because the glob is eager and evaluated at module scope, a thrown error here would take down every route, not just the blog — so a collision must fail the test suite and the build, while degrading at runtime (drop the later entry, log once) rather than crashing the site. Lana can add a recap through Lovable at any time, and a slug clash must never be able to black-hole the whole site.

Prefer keeping parse and render server-side so the Markdown payload does not inflate the client bundle. Whether that needs createServerFn or whether the loader’s default SSR behavior suffices is an execution-time call — see Open Questions.

Patterns to follow: src/lib/contactForm.ts for a single-source-of-truth library module; src/lib/__tests__/ for the existing Vitest contract-test convention.

Test scenarios:

  • A fixture Markdown file with valid frontmatter parses into a Post with title, description, pubDate, author, and tags mapped correctly.
  • Markdown body containing an ## heading, a bulleted list, a bold span, and a link renders to corresponding HTML elements.
  • getPostBySlug returns the matching post for a known article slug and for a known recap slug.
  • getPostBySlug returns undefined (not a throw) for an unknown slug.
  • A Markdown file whose slug collides with a recap slug fails the test suite with a message naming both sources, while the exported list still resolves rather than throwing.
  • The combined list contains all articles plus all recaps, sorted newest first.
  • A Markdown file missing required frontmatter fails with a message naming the offending file.

U3. Post detail route and retiring the modal

Goal: Every post reachable at /blog/<slug>; the index links out instead of opening a modal.

Requirements: R2, R7, KTD4

Dependencies: U2

Files: src/routes/blog.$slug.tsx (new), src/pages/BlogPost.tsx (new), src/pages/Blog.tsx, src/routes/blog.tsx

Approach: Add a dynamic file route resolving the slug through getPostBySlug in its loader and returning TanStack Router’s not-found for a miss, so unknown slugs render the existing NotFound component rather than crashing. Keep the route file a thin wrapper delegating to a page component in src/pages/ — that is the convention every existing route follows (src/routes/contact.tsx is six lines).

BlogPost.tsx renders both post kinds: article HTML, or recap paragraphs. Reuse the modal’s existing visual treatment — hero image with imagePosition, category eyebrow, heading, prose body — so the detail page inherits the look Lana already approved rather than introducing a new one. Include Navigation and Footer like every other page.

In Blog.tsx, replace the setActive click handler and the modal block with links to the post URL, and delete the now-unused modal state. Keep the category filter, extending it to the new article category.

The index is image-led and the eleven articles have no images. Today every card renders a hero via backgroundImage, so imageless articles would drop into the grid as empty or broken tiles. This is a visible-quality gate on a wedding site, not a detail — eleven broken tiles on the blog index would cost more trust than the articles earn.

Decided (2026-08-04): a text-forward card style for the Guides category. No placeholder image, no empty hero region. The card leads with the title and excerpt in the site’s existing type scale, and reads as a deliberate distinction between guides and real-wedding stories rather than as a missing asset. Recap cards keep their existing image-led treatment unchanged. Build it from the design tokens already in use on the index — this is not an invitation to introduce a new visual language.

Test scenarios:

  • Navigating to a known article slug renders its title and body content.
  • Navigating to a known recap slug renders its paragraphs and hero image.
  • Navigating to an unknown slug renders the not-found page, not an error boundary.
  • The blog index renders a link (not a button) per post, with href matching that post’s slug.
  • A post with no image renders a complete, intentional-looking card — no empty or broken hero region.
  • Category filtering still narrows the card list, and the article category appears as an option.
  • No modal markup or modal state remains in Blog.tsx.

U4. Distinct metadata for the static routes

Goal: Kill the duplicate-title problem on the six non-post pages.

Requirements: R1

Dependencies: none (parallelizable with U2/U3)

Files: src/routes/index.tsx, src/routes/services.tsx, src/routes/blog.tsx, src/routes/contact.tsx, src/routes/how-it-works.tsx, src/routes/testimonials.tsx

Approach: Give each route a head: with a title and description written for that page’s search intent, following the services.tsx template (KTD6) including og:url and a canonical link. __root.tsx keeps its title as the fallback for routes that set none, but no concrete page should rely on it any more.

Titles should target distinct queries rather than restating the brand — the blog index around Sea to Sky elopement guidance, contact around booking an officiant, how-it-works around the ceremony process, testimonials around reviews. Keep index.tsx on the primary “Squamish Wedding Officiant” target; that one is already right.

Leave login and the Lovable OAuth consent route alone; they should not be indexed.

Two defects confirmed against the live site via Search Console URL Inspection on 2026-08-02, both belonging in this unit:

Duplicate LocalBusiness entities. index.tsx declares one with @id: https://adventureweddings.love/#business, rich and accurate (phone, email, Google Maps in sameAs, areaServed = Squamish/Whistler/Pemberton/Sea to Sky/BC, priceRange: "$500–$950+"). services.tsx declares a second LocalBusiness with no @id, a different name, and a conflicting priceRange: "From $500". Google is being handed two inconsistent entities for one business, which undermines local ranking. Resolve to a single entity: keep the homepage block as canonical, and either give the services block the same @id or drop LocalBusiness from it entirely and let the page carry only page-level metadata. The homepage version is the better one — do not overwrite it.

og:image is an auto-generated preview screenshot. It currently resolves to pub-…r2.dev/…id-preview-<hash>--…lovable.app-….png — a machine screenshot of a Lovable preview build, not a designed share image. Every share on WhatsApp, Facebook, or iMessage renders that. Set a real, stable og:image (an existing hero asset is fine) on the root so all routes inherit it. The current URL is keyed to a preview build ID and may break on its own.

Test scenarios: Test expectation: none — static metadata with no branching logic. Covered by the Verification Contract’s crawl check, which asserts title uniqueness across all indexable routes.


U5. Article metadata, canonical, and structured data

Goal: Post pages carry per-post metadata and are eligible for article rich results.

Requirements: R1, R6

Dependencies: U2, U3

Files: src/routes/blog.$slug.tsx

Approach: Extend the detail route’s head: to derive title, description, and canonical from the resolved post, and emit BlogPosting JSON-LD with headline, description, datePublished, author, and mainEntityOfPage. Mirror the services.tsx shape (KTD6) — the LocalBusiness block there stays as-is and is not duplicated onto post pages.

The signature is head: ({ loaderData, params }) => ({ meta, links, scripts }) — verified against the TanStack Start SEO guide, which documents exactly this dynamic-post pattern. Metadata must be derived from loaderData, not hardcoded. Fall back to the root title if a post somehow resolves without a title rather than emitting an empty tag.

Test scenarios:

  • An article page’s title and description come from that article’s frontmatter, not the root default.
  • Two different post pages produce two different canonical URLs, each matching its own slug.
  • Emitted JSON-LD parses as valid JSON and declares @type: BlogPosting.
  • datePublished reflects the post’s own date.
  • A recap post page also produces post-specific metadata, not the root default.

U6. Sitemap and robots

Goal: Crawlers can enumerate every indexable URL.

Requirements: R5

Dependencies: U2

Files: src/routes/sitemap[.]xml.ts (new), public/robots.txt

Approach: Confirmed needed — Search Console URL Inspection on the homepage reports “No referring sitemaps detected” as of 2026-08-02.

A server route returning XML with Content-Type: application/xml (KTD2). The [.] escaping produces the /sitemap.xml path and matches the existing [.]lovable.oauth.consent.tsx precedent.

Enumerate the static indexable routes plus every post URL from the unified model, so newly added Markdown or recaps appear without touching the sitemap. Use each post’s date for lastmod. Exclude login, the OAuth consent route, and the not-found route.

Append a Sitemap: line to public/robots.txt, keeping the existing per-crawler allow rules intact.

Test scenarios:

  • The response carries an XML content-type header.
  • Output is well-formed XML with a <urlset> root.
  • Every post in the unified model appears exactly once as a <loc>.
  • Static indexable routes appear; login and the OAuth consent route do not.
  • All URLs are absolute against the production origin, not relative.
  • Adding a post to the model adds a corresponding entry without further changes.

U7. Port the eleven articles with an accuracy pass

Goal: Eleven accurate articles live, with no false claims and no stale pricing.

Requirements: R3, R4

Dependencies: U2 (frontmatter contract), U3 (somewhere to render)

Files: src/content/articles/*.md (11 new files)

Approach: Copy from the deprecated Astro app at apps/adventure-weddings/src/content/blog/ in the sendy-adventures-admin monorepo. Keep filenames as slugs — one article already cross-links to /blog/best-elopement-locations-squamish-bc, which resolves correctly once ported. The only other internal link is /contact, which exists. No link rewriting is required.

Do not port elopement-photographer-officiant-bc.md, rock-climbing-elopement-squamish-guide.md, or squamish-elopement-cost-breakdown.md (KTD5).

Every ported file needs an accuracy edit. A scan of the fourteen found false-premise claims in eight of them; after the three drops, these five survivors carry them:

FileClaim hitsAlso has stale pricing
squamish-vs-whistler-elopement.md2
where-to-elope-near-vancouver.md1
squamish-vs-queenstown-elopement-comparison.md1yes
intimate-elopement-just-two-of-you.md1
how-to-elope-in-squamish-bc-complete-guide.md1yes

The remaining six scanned clean but still need a read — the scan matched fixed phrases and will miss paraphrase.

Rules for the pass:

  • Remove every claim of in-house photography or videography. Where photography is genuinely part of the advice, reframe it as a partnered or separately-booked vendor.
  • Remove every claim of guiding, climbing, or certification.
  • Replace $4,500 / $9,500 / $14,500 tiers with live pricing: Wedding from $950, Elopement prices vary, Vow Renewals from $500, in-person rehearsal +$95. Where an article’s structure depends on the old tiers, rewrite the section rather than swapping numbers into a frame that no longer fits.

Scope guard. If any single article turns out to need rewriting rather than editing, stop and move it to follow-up work instead of rewriting it inside this unit. This unit is a port with corrections; the moment it becomes authorship it is a different task with a different size. The cost-breakdown article was dropped for exactly this reason (KTD5) and is the precedent to follow.

Execution note: Run the accuracy pass as a deliberate read of each file, not a find-and-replace. The scan’s phrase list is a starting point, not a completion criterion.

Human review gate (confirmed 2026-08-04): Aaron reads all eleven articles before they go live. Because publishing is a separate manual Publish click in Lovable (see the Definition of Done), merging this unit does not put the content in front of the public — that gate is real, not aspirational. So this unit may proceed without pausing for approval mid-flight. Land the port; Aaron reviews before Publish. Flag anything you were unsure about in the PR description so it gets attention rather than being buried.

Verification: Re-run the claim scan across src/content/articles/ and get zero hits; grep for the old price figures and get zero hits. Both are necessary, neither is sufficient — a human read of each article is the real gate.

Test scenarios:

  • Every file in src/content/articles/ parses through the U2 pipeline without error.
  • Each has the five required frontmatter fields.
  • Every article’s slug resolves to a rendering page.
  • An automated check over the content directory finds no occurrence of the retired price figures.
  • Eleven articles are present; the three dropped slugs are absent.

U8. Refresh the repo’s stale CLAUDE.md

Goal: Stop the repo’s own instructions from misdirecting future work.

Requirements: supports R8

Dependencies: none

Files: CLAUDE.md

Approach: The target repo’s CLAUDE.md describes a stack that no longer exists — “React Router”, “Pages in src/pages/” as the routing mechanism, and npm install / npm run dev. The repo is now TanStack Start with file-based routes in src/routes/, and carries a Bun lockfile. It also states that the monorepo Astro app “is on adventureweddings.love”, which is now false — that app is deprecated and this repo is the live site.

Correct the stack section, document the thin-route-wrapper convention, record the two-authoring-path split from this plan, and carry over the vite.config.ts duplicate-plugin warning so it is discoverable outside that one file. Preserve the Lovable-sync rules verbatim — they are still correct and are the most important content in the file.

Test scenarios: Test expectation: none — documentation.


Scope Boundaries

In scope: page metadata, post URLs, the Markdown authoring path, sitemap and robots, structured data on posts, porting and correcting eleven articles, the CI install fix that unblocks the PR, and the repo instruction refresh.

Non-goals:

  • Visual or layout redesign. The detail page reuses the modal’s existing treatment.
  • Any change to the booking, inquiry, or Calendly flow — that is the top of the funnel and out of bounds here.
  • The Supabase edge function or the Notion Bookings integration.
  • Cleaning up the deprecated Astro app. Nothing there is published; leave it.
  • Google Business Profile, vendor outreach, and the other growth channels discussed. Not code.

Deferred to follow-up work:

  • Redirect www to the apex domain. Verified 2026-08-02: www.adventureweddings.love and adventureweddings.love both resolve to 185.158.133.1 and both return HTTP 200 with no redirect — two hostnames serving identical content, which splits link equity and forces Google to guess the canonical. The canonical tags this plan adds mitigate it; a 301 fixes it properly. This is a Cloudflare redirect-rule change in the dashboard, not a code change, so it sits outside the implementation units. DNS is on Cloudflare (evangeline/damian.ns.cloudflare.com) while hosting is Lovable — keep that split in mind for anything DNS-adjacent.
  • A fresh, accurate pricing article. Replaces the dropped squamish-elopement-cost-breakdown.md (KTD5). Cost queries are among the highest commercial intent in this niche, so this should be written soon — but written from the live $950/$500 reality, not salvaged from the old tiers.
  • Article hero images. Confirmed text-only first. Images use Lovable’s *.asset.json reference system, so each article image is real work; the image field can stay optional until then.
  • Google Fonts performance. __root.tsx loads fourteen font families from the Google Fonts CDN in a single render-blocking request. That plausibly hurts Core Web Vitals and therefore ranking, but it is a separate change with its own risk of visibly altering typography.
  • Off-season legal-ceremony page and the BC marriage guide lead magnet — both discussed as growth priorities, both new content rather than SEO plumbing.
  • Static prerendering. Cloudflare supports it for TanStack Start now, and it would improve TTFB, but SSR already satisfies crawlers and it touches the Lovable-owned build.

Risks and Dependencies

RiskImpactMitigation
A Markdown dependency pulls a Vite plugin in and trips the duplicate-plugin hazardApp breaks at build; Lovable preview breaksKTD1 — import.meta.glob only. Verify no new entry appears in vite.config.ts. Build locally before opening the PR.
Lana pushes Lovable UI edits mid-branchMerge conflicts in Blog.tsxBlog.tsx is the only high-traffic file this touches. Keep the branch short, pull before starting, merge fast. Markdown files and new routes have no conflict surface.
Moving the recap array out of Blog.tsx confuses Lovable’s editorLana loses the ability to add recaps through the UIKeep the array’s shape and category strings identical and the module plainly named. Confirm with Lana, or verify in the Lovable UI after merge, before relying on it.
CI cannot pass at all todayThe required PR workflow is blockedU1, sequenced first.
Markdown parsing lands in the client bundleBundle bloat, and a hard runtime crash if the parser touches Node BufferChoose a Buffer-free frontmatter parser (U2). The usual polyfill fix requires a Vite plugin, which KTD1 forbids — so this must be avoided by dependency choice, not patched after the fact. Verify against the built client bundle.
An accuracy edit is missed and a false claim shipsInaccurate public marketing; the exact problem this plan exists to fixAutomated scan plus a deliberate human read per article. Treat the scan as necessary, not sufficient.

External dependency: two runtime packages for frontmatter parsing and Markdown rendering. No Vite plugins.


Open Questions

  1. Server-only vs isomorphic Markdown parsing. TanStack Start loaders run on the server for the initial request but on the client for subsequent navigations, so a loader that parses Markdown will ship its parser to the browser unless deliberately confined — via createServerFn, or by pre-parsing at module scope. This is what makes the Buffer hazard in U2 live rather than theoretical. Resolve by inspecting the built client bundle after U2. Execution-time, but choose the Buffer-free dependency up front so a wrong answer here degrades to bundle size rather than a crash.
  2. Not-found handling for unknown slugs. U3 specifies throwing TanStack Router’s not-found from the loader. The SEO guide documents the dynamic-route pattern but not the not-found call, so confirm the exact API (notFound() from @tanstack/react-router) against the installed version rather than assuming.
  3. Bun vs npm in CI. U1 recommends aligning CI to the Bun lockfile. Confirm Lovable’s own build does not assume npm before switching.
  4. Whether Lovable’s editor can still edit the relocated recap array. Affects only Lana’s workflow, not correctness. Verify after merge.

Success Metrics

Capture the baseline before merging. Search Console data is not retroactive — it only exists from the moment a property is verified, and there is no way to reconstruct it later. If the baseline is not captured before this ships, the work becomes unfalsifiable.

Record on a single dated page (a Notion row or a file in this repo is fine):

MetricSourceWhy
Indexed page countSearch Console → PagesShould rise from ~6 to ~26 (6 static + 11 articles + ~9 recaps). The fastest, clearest signal the plumbing worked.
Total impressions and clicks, last 3 monthsSearch Console → PerformanceThe volume baseline.
Average position, last 3 monthsSearch Console → PerformanceMovement here is slower and noisier than impressions; treat it as secondary.
Impressions split brand vs non-brandSearch Console → Performance, query filterThe metric that actually matters. Brand queries (“adventure weddings”, “rev rose”, “lana rose”) will not move from this work. Non-brand (“squamish wedding officiant”, “elope squamish”) is the real signal. Filter brand terms out and record the remainder separately.
Number of distinct non-brand queries with any impressionsSearch Console → PerformanceLong-tail breadth. Eleven articles should widen this well before they win positions.
Map-pack views, searches, and actionsGoogle Business Profile → PerformanceSeparate surface with its own baseline; the article work should not move it, so it acts as a control.

Expectation setting. Fresh pages on a low-authority domain typically take one to three months to accumulate meaningful impressions and longer to hold positions. Judge this at 90 days, not 2 weeks. The 30-day read is indexation only: are the pages in the index and picking up impressions at all? If they are not indexed by then, something is broken and that is a bug, not a patience problem.


Baseline captured 2026-08-05 (pre-publish)

Search Console was verified 2026-08-02, so this covers three days (1–3 Aug) and is the only pre-launch reading available. Nothing from this plan was live when it was taken.

MetricValue
Clicks2
Impressions55
CTR3.6%
Average position~7.8
Distinct queries13
Pages receiving impressions3 of 6 (home 52, testimonials 4, contact 2)
Brand impressions0
Indexed page countnot captured — Indexing → Pages, worth grabbing before publish

The finding that matters: this is currently a Queenstown site, not a Squamish one.

Of 13 queries, 10 are Queenstown/New Zealand and zero are Squamish. The single Sea to Sky query sits at position 43. By country, New Zealand is the largest source of impressions (19), then the US and Australia (10 each); Canada is fourth at 7, about 13% of the total, for a business based in Squamish.

More striking, the Queenstown positions are strong — #1 for “wedding planners queenstown”, #1 for “new zealand elopement packages all inclusive”, #3 for “intimate weddings nz” and “small wedding venues queenstown new zealand”. That is real ranking in a market this plan does not target, presumably earned by Lana’s NZ background and the Queenstown references in the existing copy.

Three consequences:

  1. The Squamish baseline is genuinely zero, which makes the eleven articles easy to measure: any Squamish query appearing at all is attributable to this work.
  2. Brand search is zero. Nobody is looking for “Rev Rose” or “Adventure Weddings” by name. Non-brand is therefore 100% of current traffic, and the brand/non-brand split loses its diagnostic value until brand search exists.
  3. The Queenstown position is an unplanned asset. squamish-vs-queenstown-elopement-comparison.md may be the most commercially valuable page in the corpus — but only if the NZ offering is real and current, which is still unconfirmed (see U7’s flagged items). If it is not, the honest move is to stop ranking for it rather than convert traffic that cannot be served.

Raw export: Downloads/https___adventureweddings.love_-Performance-on-Search-2026-08-05/.


Verification Contract

  • lint, test, and build pass locally and in CI.
  • A local production build serves /blog/<slug> for all eleven articles and all recaps.
  • Page source (not the hydrated DOM) shows per-page titles — confirming the metadata is server-rendered and crawler-visible.
  • Crawl the built site and assert no two indexable pages share a title.
  • /sitemap.xml returns valid XML listing every post plus the static routes.
  • robots.txt names the sitemap.
  • JSON-LD on a post page validates as BlogPosting.
  • The claim scan and the price-figure grep both return zero hits across src/content/articles/.

Definition of Done

  1. Search Console baseline captured and dated — before merge. Not recoverable afterwards; see Success Metrics.
  2. All eight units complete, CI green on the PR.
  3. Eleven accurate articles live at their own URLs; all three dropped articles absent.
  4. Every indexable page has a unique title and description, server-rendered.
  5. /blog/<slug> resolves for every post; the modal is gone; category filtering works.
  6. sitemap.xml is complete and referenced from robots.txt.
  7. Post pages carry canonical URLs and valid BlogPosting data.
  8. No new plugin in vite.config.ts.
  9. CLAUDE.md matches the actual stack.
  10. Merged to main via PR, and the Lovable preview still builds.
  11. Published from the Lovable UI, and the deploy confirmed live. Merging to main syncs code into Lovable but does not deploy it — proven 2026-08-02 on PR #4, where the live x-deployment-id was unchanged and the new file 404’d until Publish was clicked. Confirm by watching x-deployment-id change in the response headers, then fetching a new URL directly. Every unit in this plan is invisible to users and to Google until this step happens.
  12. Sitemap submitted in Search Console after deploy, and a spot-check of two article URLs through the URL Inspection tool returns no indexability errors.

Sources and Research

Codebase (target repo, origin/main @ b99309b): src/routes/__root.tsx, src/routes/services.tsx (the metadata template), src/routes/contact.tsx (thin-wrapper convention), src/routes/[.]lovable.oauth.consent.tsx (dot-escaping precedent), src/pages/Blog.tsx (post model and modal), vite.config.ts (duplicate-plugin warning), .github/workflows/ci.yml, public/robots.txt, CLAUDE.md.

Content source: apps/adventure-weddings/src/content/blog/ in sendy-adventures-admin — 14 files, ~21,000 words, consistent frontmatter. Claim scan and price-figure grep run 2026-08-02.

Positioning source of truth: apps/adventure-weddings/docs/wedding-ops-strategy.md in sendy-adventures-admin — records “No climbing elopements”, photo/video partnered out, and live pricing.

External: