Migrate a WordPress site (especially Elementor + Gravity Forms) to a static Astro site on Cloudflare Pages, with MDX content collections, a contact form via Pages Functions + Resend, JSON-LD SEO, and a real Search-Console-driven content refinement loop. Triggers on requests like "migrate WordPress to Astro", "WP to static site", "convert this wp site to MDX", "scrape WordPress to Astro". Provides the proven recipe end-to-end.
Migrate a WordPress site (especially Elementor + Gravity Forms) to a static Astro site on Cloudflare Pages, with MDX content collections, a contact form via Pages Functions + Resend, JSON-LD SEO, and a real Search-Console-driven content refinement loop. Triggers on requests like "migrate WordPress to Astro", "WP to static site", "convert this wp site to MDX", "scrape WordPress to Astro". Provides the proven recipe end-to-end.
WordPress → Astro + MDX migration
Default stack (use this unless there's a specific reason not to)
Astro 6 with output: "static", trailingSlash: "always", build.format: "directory"
Astro Content Collections (the glob loader) for MDX content
MDX via @astrojs/mdx
React (only) for interactive components (@astrojs/react) — contact form, mobile nav
Tailwind v4 via @tailwindcss/vite with @theme CSS-first tokens; legacy :root --foo mirrors for back-compat
Cloudflare Pages (static) + Pages Functions for the contact endpoint
Resend for transactional email; call the HTTPS API directly from the Function (no SDK — keep the bundle small)
Zod for validation
bun for everything; oxlint, oxfmt
@astrojs/sitemap for sitemap-index.xml + sitemap-0.xml
@astrojs/check for typecheck
Why Astro over TanStack Start: for a content-heavy marketing site (44 pages + case studies + news), Astro's static output is dramatically simpler, faster, cheaper, and avoids the Cloudflare-Worker-runtime gotchas that show up immediately in TanStack Start (no fs at runtime, content must be inlined via import.meta.glob, etc.). Use TanStack Start only if the site needs SSR for personalisation, auth, or cart.
getCollection(name) in getStaticPaths() for [slug].astro pages. Use render(entry) for the MDX <Content/>.
Content migration recipe
Get sitemaps first — every WP site exposes /sitemap_index.xml. Enumerate every child sitemap (page-sitemap, post-sitemap, custom post types, category-sitemap). This is the source of truth for URLs to migrate.
Don't trust the WP XML export for content — usually stale and Elementor stores page bodies as serialized JSON in <wp:postmeta>. Use it only for: author/byline data, the rare native post body, and to fill in the dates on a date-permalinked post that the live HTML doesn't expose.
Scrape live HTML with cheerio. Selectors that usually contain real content: .entry-content → main article → main → fall back to the longest div[data-elementor-type="wp-page"] by text length. Most Elementor sites don't have .entry-content.
Convert HTML→MD with turndown (heading rule atx, code block fenced). Custom rules for <figure>+<figcaption>, <blockquote>, accordion/tab widgets.
Rewrite image URLs during turndown: https://<site>/wp-content/uploads/PATH → /images/PATH. Download each to public/images/PATH preserving the original path (so CDN paths stay stable). Stream with fetch + Bun.write. Skip already-downloaded files.
Frontmatter from <head>: title from og:title or <title> (strip the site-name prefix); description from meta[name=description] or og:description; OG image from og:image. Maintain a SITE_NAME_PATTERNS regex list.
Strip filler images: maintain a DECORATIVE_IMG_PATTERNS list (dark_logo, white_logo, quotation, placeholder, favicons). Drop them at scrape time.
Strip embedded nav menus: count internal-link ratio inside <ul> elements — if >60% of href values match top-level routes, it's the navigation, drop it.
Strip Cloudflare email obfuscation links — /cdn-cgi/l/email-protection#... — at scrape time AND turn off Scrape Shield → Email Obfuscation on the destination zone after deploy.
Idempotent re-runs — scrape script should never duplicate files; only re-download missing images.
Permalink preservation (Astro static)
WP pattern
Astro route
/<slug>/
src/pages/<slug>.astro
/<cat>/<slug>/
src/pages/<cat>/[slug].astro
/<YYYY>/<MM>/<DD>/<slug>/
src/pages/<cat>/[year]/[month]/[day]/[slug].astro
/category/<cat>/
src/pages/<cat>/category/[cat].astro
Custom post type /<cpt>/<slug>/
src/pages/<cpt>/[slug].astro
Set trailingSlash: "always" + build.format: "directory" in astro.config. Cloudflare Pages serves /foo/index.html on /foo/ requests; /foo (no slash) does a 308 → /foo/ automatically.
For internal links — always emit with the trailing slash, or you get a 308 hop on every nav click that shows up as links/redirect-chains warnings in squirrelscan.
SEO — Base.astro & JSON-LD
Base.astro should:
Compose title as ${title} | ${site.name} only when total length ≤ 60; otherwise use raw title alone
Default og:image to site.defaultOgImage (a generated 1200×630 brand card; generate once with magick -size 1200x630 xc:NAVY -fill GOLD ... legalaccess-og.jpg)
Emit <meta name="author"> on ogType="article" pages
Google Fonts via <link rel="preconnect"> + <link rel="preload" as="style"> + <link rel="stylesheet"> — never@import url("https://fonts.googleapis...") in CSS (creates a critical request chain)
JSON-LD on every law-firm site:
Home: LegalService with address (primary office), location[] of Place (all offices), aggregateRating, sameAs, areaServed
Per location page: LegalService keyed to that single office's address + BreadcrumbList
News article pages: Article with author, datePublished, image
Squirrelscan's schema/local-business rule wants a singleaddress object — pass an array as location[] instead
Pages Functions for the contact form
Drop the file functions/api/contact.ts at repo root. CF Pages auto-loads functions/** and routes them. The Astro static output is in dist/; CF serves Functions alongside static assets — no extra config.
Honeypot the right way: a real off-screen <label> containing a real <input> with tabIndex={-1} and autoComplete="off". Never use aria-hidden on a focusable input — squirrelscan flags it as a11y/aria-hidden-focus.
Cloudflare Pages deployment
wrangler.jsonc
{"$schema":"node_modules/wrangler/config-schema.json","name":"legalaccess","pages_build_output_dir":"./dist","compatibility_date":"2025-09-02","compatibility_flags":["nodejs_compat"],"vars":{"CONTACT_TO_EMAIL":"sam@example.com","CONTACT_FROM_EMAIL":"Firm Name <noreply@mail.example.com>","RESEND_SEND_DOMAIN":"mail.example.com"}}
Critical lesson: wrangler pages deploy only auto-binds secrets to deployments. Plain env vars set via the dashboard or PATCH /pages/projects/{name} deployment_configs API do not flow into deployments triggered by wrangler. Put non-secret vars in wrangler.jsonc"vars" so they're config-as-code and bind to every deploy. Secrets (API keys) stay set via:
wrangler pages secret put RESEND_API_KEY --project-name=<name>
Custom domain wiring (via API, when wrangler OAuth lacks DNS scope)
TOKEN=<bearer>; ACCT=<account-id>
# Register both apex + www on the Pages projectfor D in example.com www.example.com; do
curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/<proj>/domains" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data "{\"name\":\"$D\"}"done
www: CNAME → <project>.pages.dev, proxied (or remove the Pages binding if you prefer a CF redirect rule to apex)
Delete any existing A/AAAA records pointing at the old WP origin.
If the apex returns HTTP 522 after the change, an A record pointing at a dead WP origin is still in place — find it and delete it.
If the dashboard shows "CNAME record not set" for the Pages domain, the CNAME isn't propagated yet or there's a competing record. Verify with dig CNAME example.com @1.1.1.1.
CF settings to flip after launch
Scrape Shield → Email Address Obfuscation: OFF — otherwise CF rewrites every mailto: into /cdn-cgi/l/email-protection#..., which (a) shows [email protected] in audits and SERPs and (b) is flagged as 50+ "broken links" by squirrelscan.
(Optional) Speed → Brotli; Caching → Browser TTL.
Map embeds — show the place card, not just the address
?q=address&output=embed returns a plain map. To get the place card with rating + reviews in-iframe (which is what clients want), use the Maps Embed API with each office's place_id:
The Maps Embed API is free, no quota (unlike other Maps APIs). Steps:
Get each office's place_id from the client (open Google Maps → click the business → URL contains !1s0x...ChIJ... — or just ask them for the ChIJ string).
Create an API key in https://console.cloud.google.com/apis/credentials. Enable only "Maps Embed API". Restrict to HTTP referrers (https://example.com/*, https://*.pages.dev/*, http://localhost:3000/*).
Store as PUBLIC_GOOGLE_MAPS_EMBED_KEY (PUBLIC_ prefix → exposed at Astro build time). Add to .env.local for build, and as a plain var on Pages for future git-builds.
Save placeId per office in src/config/site.ts.
Analytics — GA4 + auto-tracked conversions
Drop a small <Analytics> component into Base.astro <head> that emits both the gtag loader and a single delegated click listener. No per-link onclick attributes anywhere.
<!-- src/components/Analytics.astro -->
{gaId && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
<script is:inline define:vars={{ gaId }}>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
window.gtag = gtag;
gtag("js", new Date());
gtag("config", gaId, { send_page_view: true });
// Delegated listener: covers every <a> click site-wide, even mobile-nav links
document.addEventListener("click", function (e) {
let el = e.target;
while (el && el !== document.body && el.tagName !== "A") el = el.parentNode;
if (!el || el.tagName !== "A") return;
const href = el.getAttribute("href") || "";
const section = el.closest("header") ? "header" : el.closest("footer") ? "footer" : "body";
const label = (el.textContent || "").trim().slice(0, 80);
if (href.startsWith("tel:")) gtag("event", "phone_click", { phone_number: href.slice(4), link_text: label, section });
else if (href.startsWith("mailto:")) gtag("event", "email_click", { email_address: href.slice(7), link_text: label, section });
else if (/\/contact(\/|$|#)/.test(href)) gtag("event", "cta_click", { cta_type: "free_consultation", link_text: label, section, link_url: href });
else if (/^https?:\/\//.test(href)) {
try { const u = new URL(href); if (u.hostname !== location.hostname)
gtag("event", "outbound_click", { outbound_url: href, outbound_domain: u.hostname, link_text: label, section });
} catch {}
}
}, true);
</script>
</>
)}
Contact form fires contact_form_submit from the React component's success branch:
Tell the client to mark each event as a Key Event in GA4 (Admin → Events → Mark as key event) after the event fires the first time. The toggle isn't available until then.
Live data on a static site (KV + Pages Function + client refresher)
When the client needs numbers that update without redeploys (Google reviews, inventory counts, status indicators), use stale-while-revalidate via a single Pages Function. No separate Worker, no cron job, no scheduled handler.
Parses fetched_at; if Date.now() - parsed > TTL_MS (e.g. 1 hour), call ctx.waitUntil(refresh()) and return the cached payload immediately
On cache miss with no value, refresh synchronously so the first request gets real data
Set response cache headers: cache-control: public, max-age=300, stale-while-revalidate=3600
Upstream call — fetch the source-of-truth API (Google Places API New for reviews) using a secret env var:
echo -n "AIza..." | wrangler pages secret put GOOGLE_PLACES_API_KEY --project-name=<proj>
Client refresher — a tiny inline <script is:inline> in Base layout that fetches /api/reviews on every page load and updates [data-place-id] and [data-review-badge="total"] elements via setText helpers. Server-side initial paint uses placeholder values from site.ts so the page is correct from byte 1, and updates if the API has newer data.
Cost analysis (Google Places API New as example): at 1h TTL × 2 places × 24h × 30 days = 1,440 calls/month. Free tier is 5,000/month for Place Details. Effectively free.
Custom 404
Create src/pages/404.astro reusing the Base layout + Kicker/Button. Astro emits dist/404.html and Cloudflare Pages auto-serves it with HTTP 404 status for unmatched routes. Without this file, CF Pages falls through to index.html and returns HTTP 200 with the homepage for unknown URLs — bad for SEO and confusing for users.
Add a card grid linking to the major sections so users can self-recover.
Brand kit — use the real assets from day one
Don't ad-hoc invent logo marks. Ask the client for the brand kit (usually a folder with Logomark/, Full Logo/, 01 Colour/, 02 Black and White/ subdirs containing SVG, PNG, EPS variants). Standard set you'll need in public/images/brand/:
karnib-logomark-navy.svg # small mark, light bg
karnib-logomark-white.svg # small mark, dark bg
karnib-logomark-cream.svg # small mark, off-white/cream variant
karnib-fulllogo-navy-horizontal.svg
karnib-fulllogo-white-horizontal.svg
If the project doesn't have a brand kit yet, isolate the placeholder mark in one<BrandMark> component used everywhere — then the eventual swap is a single-file edit.
Favicon set (full modern)
Generate from a single 512×512 transparent-bg PNG of the logomark:
--force-device-scale-factor=2 captures at 2400×1260 for sharp text at the final 1200×630. The HTML lives in version control so you can iterate on the design like any other component.
Schema migrations across MDX content
When renaming or restructuring a content collection schema field (e.g. location: string → locations: string[]), the consumers won't fail the build — they'll just render undefined. Always:
Pos 1–10 (page 1) — protect. Especially brand queries.
Pos 8–15 (edge of page 1) — promote with a small content nudge. Add the exact query phrase as an H2 on the target page.
Pos 11–30 (page 2-3, high impressions) — biggest ROI. Expand content with NSW-specific terms, FAQ section, schema.
Pos 30+ (buried, high impressions) — content gap. Either expand existing page substantially, or write a dedicated landing.
Common buckets in legal niches:
" nsw" + " defence" — already have pages, need expansion with statute refs
"can i get a section 10 for X" — long-tail FAQ targets, write a Q&A blog post
" " — needs dedicated /locations/<city>-<service>/ page with LegalService JSON-LD and the office's address
4. Submit indexing requests via API
The google-indexing skill submits up to 200 URLs/day. Run after each meaningful content push:
google_indexing submit --file urls.txt -f table
Note: technically the API requires Owner-level access in GSC, but it accepts submissions for Restricted users on verified domain properties in practice.
5. Repeat. Track week-over-week position changes for the priority cluster.
Content depth — for legal sites especially
For a legal site, every claim must be grounded. The audit isn't enough — the content has to be legally accurate because the firm's reputation rides on it.
Approach:
For each priority page, identify the NSW Act + section that creates the offence.
Use Judicial Commission of NSW Local Court Bench Book as the authoritative source for penalty tables — it accepts WebFetch where AustLII and legislation.nsw.gov.au usually 403.
Quote actual statutory text as a blockquote where possible. Italicise Act names (e.g. Crimes Act 1900).
Don't invent case law. If you cite a case (e.g. R v Whyte for s 52A dangerous driving), web-search to confirm citation + relevance before committing.
Have subagents flag any figures they couldn't verify — and verify them yourself before deploying.
Add an FAQ section at the bottom of each offence page — these are the long-tail queries from GSC ("can I get a section 10 for mid range pca", "common assault dv t2") that win ranking on page 2.
Cross-link related pages (/criminal-law/common-assault/ → /criminal-law/breach-of-an-apprehended-domestic-violence-order/) and case studies (/case-studies/<matching-outcome>/).
Useful selectors / quirks
Gravity Forms uses input_<id> names. Either rewrite to friendly names or preserve them if external integrations depend on them.
Rank Math SEO writes meta tags directly; OG fields are reliable.
Elementor headers often have duplicate nav (desktop + mobile). Dedupe by href when scraping.
WP XML serialised data uses PHP serialize (a:5:{s:5:"width";i:512;...}). Skip unless you really need it.
NSW penalty unit = $110 (as of 2024). Don't confuse "20 penalty units" with "$20" — multiply by 110.
Astro 6 content collection IDs drop the .mdx and any index suffix. entry.id is the slug.
bun run build produces dist/. Deploy with wrangler pages deploy dist --project-name=<name> --branch=main --commit-dirty=true.
When NOT to use this skill
Headless WP staying with WP — different problem
WooCommerce — needs a real e-commerce layer (Shopify, Medusa, etc.); Astro static won't cover it
Sites with thousands of posts where scraping is too slow — use a Postgres dump + custom parser instead
Sites needing SSR for personalisation, auth, or per-user content — use TanStack Start or Astro with SSR adapter