WordPress to Astro Migration Brief
The job is to move mobilebillboardsf.com off WordPress and onto a static Astro site hosted on Cloudflare Pages, deploying from GitHub. This is the exact process we used for mobilefoodexperts.com, and every other Boki business site runs the same stack.
Use it two ways: as the standard a candidate should be able to describe and reproduce, and as the source for the interview questions and the practical test in sections 6 and 7.
1. The site being moved
What a crawl of mobilebillboardsf.com shows today. Hand this inventory to the candidate as part of the practical test, then check whether they find anything it misses.
| Item | Current state |
|---|---|
| Platform | WordPress with Elementor 3.34, Hello Elementor child theme, All in One SEO 4.9, LiteSpeed cache. Already fronted by Cloudflare. |
| Business | LED truck advertising in Sioux Falls, SD. Co-owned with Mirko Spiric. Starter package $995. Premium positioning against Lamar and BookYourBillboard. |
| Lead capture | GoHighLevel. Booking widget on /calendar/ and /download-pricing/, survey widget on /schedule/, both loaded via link.msgsndr.com/js/form_embed.js. |
| Subdomain to preserve | account.mobilebillboardsf.com resolves and is linked from the homepage. Its DNS record must survive the cutover. |
| Tracking | No GTM, GA4 or Meta Pixel tags detected on the homepage. All of it gets installed fresh. |
| Media | Roughly 15 images on the homepage, favicon uploaded November 2025. Gallery page has no third-party embeds. |
| Robots | Blocks /wp-json/, feeds, tags, categories and search. The sitemap index is referenced but returns empty to non-browser requests. |
Every URL that has to map somewhere
Thirteen pages and four posts. All of these need either a rebuilt page or a 301 in the redirects file. Posts sit at the root with no /blog/ prefix, which is worth keeping so no post URL changes.
| Type | Path | Notes |
|---|---|---|
| Page | / | H1 "Mobile Billboard SF in Sioux Falls". Sections: truck intro, service packages, FAQ, truck gallery. |
| Page | /about-us/ | |
| Page | /our-team/ | |
| Page | /mobile-billboard-truck-route/ | Route information. Likely a map. |
| Page | /led-screen-trucks-faqs/ | Candidate for FAQPage schema. |
| Page | /gallery/ | Image heavy. WebP conversion matters most here. |
| Page | /vip-perks/ | |
| Page | /mobile-billboard-ads-blog/ | Post listing. Becomes the resources index with CollectionPage schema. |
| Page | /calendar/ | GHL booking widget r6UtycCT0G1VJdgsJfXW. |
| Page | /download-pricing/ | Same GHL booking widget. |
| Page | /schedule/ | GHL survey widget 1vIzUIMaedPsriZrJiPM. |
| Page | /privacy-policy/ | |
| Page | /terms-and-conditions/ | |
| Post | /mobile-billboards-how-they-work-cost-and-effectiveness/ | |
| Post | /mobile-billboards-vs-traditional-billboards/ | |
| Post | /the-local-guide-to-effective-mobile-billboard-advertising-in-sioux-falls/ | |
| Post | /the-top-5-benefits-of-mobile-billboard-advertising-for-local-businesses-in-sioux-falls/ |
The homepage links to /calendar, /schedule and /download-pricing without trailing slashes, and WordPress 301s them. On the new site those links get the slash directly so no redirect is needed.
2. The target stack
A candidate should recognise every row here and be able to explain why each piece is there. This is what "done" looks like for us.
| Layer | What we use | Why it matters |
|---|---|---|
| Framework | Astro 5, output: 'static' | Pure static HTML. No server, no database, no plugin updates. |
| Source control | GitHub, private repo under the mobilefoodexperts org | The repo is the single source of truth. The main branch is production. |
| Hosting | Cloudflare Pages, connected to the GitHub repo | Every push to main builds and deploys in about 60 seconds. Free tier. |
| Build settings | Preset Astro, command npm run build, output dist | Package.json must sit at the repo root, or the root directory has to be set in Cloudflare. |
| DNS and SSL | Cloudflare DNS, certificate issued automatically | Domains are registered at Porkbun and pointed at Cloudflare nameservers. Falls Fire Protection is the exception, bought at Cloudflare directly. |
| URLs | trailingSlash: 'always', sitemap from @astrojs/sitemap | Prevents 308 redirect chains. Every internal link ends in a slash. |
| Redirects | public/_redirects | Old WordPress slugs 301 to the new pages. Cloudflare Pages reads this file natively. |
| Security headers | public/_headers | Content Security Policy, nosniff, frame options. Never set in astro.config. |
| Analytics | Google Tag Manager, GA4 configured inside GTM | GTM snippet in the head, noscript after the body opens. |
| Pixels | Meta Pixel and Microsoft Clarity hardcoded in BaseLayout | Meta Pixel through GTM fired unreliably for us, so it goes direct. |
| Lead capture | GoHighLevel forms, booking widget, chat widget | Embedded as iframes or scripts, so CSP has to allow the GHL domains. |
| SEO files | robots.txt, sitemap-index.xml, llms.txt, JSON-LD schema | Old /wp-* paths stay blocked in robots. LLM crawlers explicitly allowed. |
| Auditing | Search Atlas crawl, plus Node scripts hitting PageSpeed, Search Console and GA4 APIs via a Google service account | Before and after numbers for every SEO or performance change. |
| Editing workflow | Claude Code in the project folder, a CLAUDE.md context file in the repo root | Not required for the developer, but they should be comfortable with the repo being edited this way. |
3. The migration process
These are the phases in the order we ran them. The order matters: SEO foundations and tracking go in before launch, and the redirects go live the same minute the DNS switches.
Export and inventory the WordPress site
- Tools → Export → All content. This produces one WXR XML file. Ours was 1 MB, dated the day before the first Astro commit.
- Download the media library, or at minimum every image actually used on a page.
- Pull the full URL list from two places: the WordPress sitemap and Google Search Console's pages report. GSC catches junk URLs that still get impressions, such as
/home-7891-6896and/book-an-appoinment-7238, which a sitemap will not show. - Note every form, booking widget, tracking script and third-party embed on the old site. These are the things that silently break in a migration.
Plan the new site before writing code
- Brand foundation: colours, heading and body fonts, a transparent-background logo at 600px or wider.
- Page map with a meta title under 60 characters and an H1 for every page.
- CTA hierarchy: the one action a visitor should take, then the second.
- A URL mapping table, old slug to new slug. This becomes the redirects file later.
Set up the environment and the repo
- Node LTS, Git, a GitHub account with access to the org.
npm create astro@latest, then add@astrojs/sitemapandsharpfor image processing.- Commit early. Our first commit was the scaffold, the second was the gitignore. Never commit
node_modulesordist. The MFE repo had to fix that on day three.
// astro.config.mjs — the three settings that are not optional export default defineConfig({ output: 'static', site: 'https://yourdomain.com', trailingSlash: 'always', integrations: [sitemap()], });Build the layout and rebuild the pages
- One
BaseLayout.astroowns the head: charset, viewport, description, dynamic canonical fromAstro.url.pathname, Open Graph, Twitter card, favicons, webmanifest, preconnects, fonts loaded with link tags and an async onload pattern, never@import. - Header and Footer as components. Footer carries the address as a Google Maps link, phone, email and social icons.
- Pages rebuilt one at a time from the WordPress content. Copy is cleaned, not pasted with WordPress markup and inline styles.
- Every image converted to WebP at 80% quality, with explicit width and height. Hero and logo load eagerly with
fetchpriority="high", everything else lazy. - A branded 404 page. Skip-to-content link and ARIA on the mobile menu toggle.
- One
Lay the SEO foundation
robots.txtallows everything, blocks the old/wp-content/,/wp-admin/,/wp-includes/,/wp-login.phpand/xmlrpc.phppaths, explicitly allows GPTBot, ClaudeBot, Google-Extended, CCBot and anthropic-ai, and points atsitemap-index.xml.- JSON-LD on every page: LocalBusiness or ProfessionalService, Organization with all social profiles in sameAs, BreadcrumbList. Page-specific: FAQPage, Article, CollectionPage, ContactPage, Person, WebApplication for calculators.
public/llms.txtlisting every page and article URL.- Hard limits: meta title 60, OG title 60, Twitter title 55, Twitter description 125, meta description 160. No "Brand | Brand" duplicate in titles.
Install tracking
- GTM container snippet in the head, noscript iframe right after the body opens.
- GA4 configured as a Google Tag inside GTM with an all-pages trigger, then published.
- Meta Pixel and Microsoft Clarity pasted directly into BaseLayout, not routed through GTM.
- Verify in GA4 realtime and Meta Events Manager before launch, not after.
Write the security headers
A single
public/_headersfile. The CSP has to allow every third party the site actually loads: Google Tag Manager, Google Analytics, Facebook, GoHighLevel's api, widgets and services domains, the FastPayDirect payment host, YouTube frames and Google Fonts. Add nosniff, SAMEORIGIN, a strict referrer policy and a permissions policy that turns off camera, microphone and geolocation.A developer who ships a CSP without testing the GHL booking widget will break lead capture on launch day.
Deploy to Cloudflare Pages
- Push the repo to GitHub. In Cloudflare: Workers & Pages → Create → Pages → Connect to Git → pick the repo.
- Framework preset Astro, build command
npm run build, output directorydist. Save and deploy. Cloudflare gives a*.pages.devpreview URL immediately. - Review the site on the preview URL. Test every form, the booking widget, the chat widget and the tracking on the preview before touching DNS.
Cut over the domain
- Move the domain's nameservers to Cloudflare if they are not already there. Wait for the zone to activate. mobilebillboardsf.com already sits behind Cloudflare, so this step is only a check that the
accountsubdomain record and the mail records stay intact. - Pages project → Custom domains → add the apex domain and www. Cloudflare writes the DNS records and issues the certificate automatically.
- The
_redirectsfile must already be in the repo at this point. Old slugs 301 to new pages, old/blog/to/resources/, old/about/to/about-us/, and any slash-less versions of new URLs to their slashed form. - Google Search Console: verify the domain property, submit
sitemap-index.xml. Update the Google Business Profile website link. - Keep the WordPress host alive for a couple of weeks in case something was missed, then cancel it.
- Move the domain's nameservers to Cloudflare if they are not already there. Wait for the zone to activate. mobilebillboardsf.com already sits behind Cloudflare, so this step is only a check that the
Audit, fix, and set the ongoing workflow
- Run a Search Atlas crawl, export the issues list, fix everything at severity 8 and above, re-crawl. Target 950+ per page.
- Run PageSpeed on every page in the sitemap, mobile and desktop. Fix layout shift first. Our biggest win was a sitewide CLS of 0.275 caused by a
translateYfade-in animation. Removing it took CLS to zero. - Set up a Google Cloud service account with PageSpeed, Search Console and Analytics Data APIs, grant it viewer access in GSC and GA4, and keep the JSON key outside the repo. Audit scripts save before and after JSON so every change is measurable.
- Ongoing change workflow: edit,
npm run buildlocally, review titles and descriptions, commit, push. Cloudflare deploys in about a minute. Re-crawl if SEO changed.
4. How it went for MFE
The commit history of the MFE repo shows the real pace. A candidate who has done this before will recognise the shape: a burst of scaffolding, then a dense SEO and tracking day, then a week of audit-driven fixes.
| Date | What landed |
|---|---|
| Mar 3, 2026 | WordPress export taken. Astro scaffold committed. |
| Mar 4 | Repo on GitHub, gitignore, CLAUDE.md context file, first Cloudflare deploys. Team page, coach photos, signup URLs. |
| Mar 6 | GTM installed. Meta titles, descriptions, canonicals, OG and Twitter tags. Sitemap, robots.txt, structured data. Favicons, 404, FAQ schema, webmanifest. Images to WebP. Accessibility and CLS prevention. Node_modules and dist removed from tracking. Resources section and first three articles. |
| Mar 10 to 11 | Audit-driven fixes: title length, description trims, llms.txt, inline styles moved to classes, Meta Pixel added direct, orphan pages linked, trailing slashes fixed sitewide, breadcrumb and ContactPage schema, Google Maps links, one build error fixed by moving schema computation into frontmatter. |
| Mar 22 | Performance pass: CLS 0.275 to 0, fetchpriority on hero images, async font loading, unused font weights trimmed, calculator accessibility 84 to 93. Audit scripts for PageSpeed, GSC and GA4 committed. |
| Since | Redirects for old WordPress URLs found in GSC. Articles, landing pages and calculators added weekly. 194 commits total. |
Realistic effort: the core MFE migration was three working days for a site of similar size. Falls Fire Protection, a new build on the same stack, took 23 commits. Mobile Billboard SF at 17 URLs with two GHL embeds and a gallery should land in two to three days for someone who has done it before.
5. Details that prove experience
Anyone can follow an Astro tutorial. These are the things we learned in production. A strong candidate will volunteer several of them unprompted.
- Trailing slashes are a contract. Setting
trailingSlash: 'always'is only half of it. Every internal link, canonical and sitemap entry has to match, or you get 308 chains that Search Console reports as redirect errors. - Redirects come from Search Console, not from the sitemap. The URLs still earning impressions after a migration are usually junk slugs WordPress generated years ago. Those are the ones that need 301s.
- Keep the WordPress paths blocked in robots.txt on the new site. Google's index still holds them for months. Blocking stops crawl budget being wasted on 404s.
- CSP lives in
_headers, redirects in_redirects, both inpublic/. Cloudflare Pages reads them from the build output. Putting headers in astro.config does nothing on a static build. - Meta Pixel direct, GA4 through GTM. A tag manager pixel that fires on 80% of pageviews is worse than useless because you will trust the numbers.
- Fonts via link tags with preconnect and an async onload pattern. A CSS
@importfor Google Fonts is render-blocking and shows up immediately in PageSpeed. - Scroll animations must not use transform translateY. Opacity only. Otherwise every section shifts on load and CLS fails sitewide.
- Explicit width and height on every image, WebP everywhere, lazy loading on everything below the fold.
- Dynamic canonical and og:url from
Astro.url.pathname. Hardcoding the homepage URL into the layout means every page claims to be the homepage. - No duplicate brand name in titles. "Mobile Food Experts | Mobile Food Experts" is a real mistake that a template produces and an auditor penalises. Pass a separate OG title prop.
- Build locally before every push. A failed Cloudflare build leaves the old version live, which is safe, but it should never be how you find out about a syntax error.
- Credentials never enter the repo. The service account JSON key sits outside the project and is referenced by absolute path.
- Scripts use
.cjswhen package.json declares"type": "module", or Node refusesrequire(). - Third-party embeds decide the CSP. GHL alone needs three domains allowed. Test every widget on the preview URL with the headers file in place.
6. Interview questions
Ask these in order. Each one has a specific answer we already know from doing the work, so a vague reply is easy to spot. Do not lead with the answer.
WordPress side
You have admin access to a WordPress site that needs to become a static Astro site. What do you take from it, and how?
WXR XML export from Tools, the media library, the full URL list from the sitemap and from Search Console, and an inventory of forms, embeds and tracking scripts. Mentions checking for plugin-generated pages and junk slugs.
"Copy the text from each page." No mention of URLs, media or third-party scripts.
After launch, Search Console shows impressions for URLs that do not exist on the new site. What happened and what do you do?
Those are old WordPress URLs still in Google's index. Add 301s in the redirects file to the closest new page. Keep /wp-* blocked in robots. Expect it to take weeks to clear.
"Ask Google to remove them" or "they will go away on their own."
Astro
What goes in astro.config.mjs for a static marketing site on Cloudflare Pages, and why?
Static output, the site URL so the sitemap and canonicals resolve, trailingSlash: 'always' to avoid redirect chains, the sitemap integration. Should be able to say what happens to internal links if the trailing slash setting is inconsistent.
Cannot name the trailing slash setting or does not know why it matters.
Where do the canonical URL and og:url come from on each page?
Built in the base layout from Astro.url.pathname joined to the site origin. Never hardcoded.
Hardcodes the homepage, or does not know the difference between canonical and og:url.
PageSpeed reports a cumulative layout shift of 0.27 on every page. Where do you look first?
Images without width and height, web fonts swapping in, and scroll or load animations that move elements with transform. Knows opacity-only animation is the fix for the last one.
"Add lazy loading" as the only answer, or does not know what CLS measures.
GitHub and Cloudflare
Walk me through connecting a GitHub repo to Cloudflare Pages and getting a custom domain live.
Connect to Git, choose repo, Astro preset, npm run build, dist, root directory if package.json is nested. Preview on pages.dev, then Custom domains adds DNS and SSL automatically when the zone is on Cloudflare. Knows nameservers may need to move first.
Describes uploading a zip, or cannot say what the build output directory is.
Where do 301 redirects and security headers live on a Cloudflare Pages static site?
_redirects and _headers in public/, copied into the build output. Knows a CSP has to list every third-party script and frame host the page uses.
Wants to put them in astro.config, or suggests a WordPress-style plugin.
A push to main fails the Cloudflare build. What is live right now and what should have happened before the push?
The previous deploy stays live. The developer should have run the build locally first. Reads the Cloudflare build log to find the error.
Thinks the site goes down, or has no local build habit.
Tracking and integrations
How do you install GTM, GA4 and the Meta Pixel on an Astro site, and in what order?
GTM snippet in the head and noscript after body in the base layout. GA4 as a Google Tag inside GTM, published. Pixel hardcoded in the layout. Verifies with GA4 realtime and Meta Events Manager before launch.
Reaches for a WordPress plugin, or does not know GTM needs publishing.
The site embeds a GoHighLevel booking calendar on two pages and a survey on a third. What can break those in a migration?
CSP blocking the GHL script and frame domains. Form redirects or thank-you pages that changed URL. Unpublished GHL workflows. Tests each on the preview URL.
"Paste the embed code" with no awareness of headers or testing.
Working style
We edit these repos with Claude Code, and there is a CLAUDE.md context file at the root. How would you work alongside that?
Keeps the context file current when conventions change, follows the conventions in it, keeps commits small and descriptive so the history stays readable.
Dismissive, or wants to restructure the project before understanding why it is shaped this way.
7. Practical test
A paid trial on the real job beats any interview. Give the candidate the WordPress export of mobilebillboardsf.com, the logo and truck photos, the inventory in section 1, and this brief. Time box: three working days. Stage one is the first day and is enough to decide whether to continue.
Stage one, day one: foundation
- A private GitHub repo you can inspect, with a clean commit history and no
node_modulesordisttracked - A Cloudflare Pages preview URL deployed from that repo, on their own account
- Homepage, about, our team, FAQ, and the calendar page with the real GHL booking widget loading
- astro.config with static output, site URL, trailing slashes, sitemap
- BaseLayout with dynamic canonical, Open Graph, Twitter card, favicons, preconnected fonts via link tags
_headerswith a CSP that still lets the GHL widget load- robots.txt with WordPress paths blocked, sitemap-index referenced
Stage two, days two and three: the rest
- All 13 pages and 4 posts rebuilt at their existing paths, posts kept at the root
- Blog listing page with CollectionPage schema, Article schema on each post, FAQPage schema on the FAQ page
- LocalBusiness and Organization JSON-LD with the Sioux Falls address, phone, and social profiles
- Gallery rebuilt with WebP images, explicit dimensions, lazy loading
_redirectscovering every old URL that changed, plus the slash-less versions of calendar, schedule and download-pricing- GTM snippet in place with a placeholder container ID, Meta Pixel placeholder direct in the layout
llms.txtlisting every page and post- A README with the deploy steps, the DNS cutover checklist including the
accountsubdomain, and what they would do next
How to grade it
- Run PageSpeed on the preview URL. Mobile performance above 85 and CLS under 0.1 on the homepage and gallery is the bar.
- Open
/calendarwithout the slash and confirm one redirect, not two, then confirm the booking widget actually renders with the headers file in place. - View source on a blog post and confirm the canonical is that post, not the homepage, and that Article schema is present.
- Diff their redirect file against the URL table in section 1. Anything missing is a lost ranking.
- Check the commit history. Fifteen to thirty meaningful commits is healthy. One giant commit is a warning sign.
- Ask them what they would change with another day. The answer should include Search Console setup, real GTM and GA4 IDs, and a Search Atlas or Lighthouse audit.
Do not hand over Cloudflare, GitHub org, GHL or Google credentials for the trial. They deploy to their own Cloudflare account and their own GitHub, and the live site stays untouched. If hired, the repo transfers into the mobilefoodexperts org, the Pages project is recreated under Boki's account, and the DNS cutover happens with Boki present.
8. Scoring sheet
Score each area 0 to 3. Under 14 total is a no. Weight the practical test heaviest.
| Area | 0 | 3 |
|---|---|---|
| WordPress export and URL inventory | Copies page text by hand | WXR export, media, sitemap plus GSC URL list, embeds inventoried |
| Astro fundamentals | Cannot explain the config | Static output, trailing slashes, dynamic canonicals, layouts and components |
| GitHub and Cloudflare Pages | Manual uploads | Git-connected auto deploy, custom domain, preview URLs, clean history |
| SEO migration hygiene | No redirects, default robots | 301 map from GSC, WP paths blocked, schema, sitemap submitted |
| Headers and CSP | Unaware | Written in _headers, tested against every embed |
| Tracking | Plugin mindset | GTM plus GA4 inside it, pixel direct, verified before launch |
| Performance | No measurement | WebP, dimensions, font loading, CLS fixed, before and after numbers |
| Practical test | Not delivered in the time box | All deliverables, PageSpeed bar met, readable README |