Writing
How I Migrated WordPress to Astro on Cloudflare Pages (Without Breaking SEO)
A practical WordPress-to-Astro migration guide covering WXR exports, Markdown conversion, redirects, media, SEO checks, and Cloudflare Pages deployment.
I recently moved this site from WordPress to a static Astro site hosted on Cloudflare Pages.
The final deployment was the easy part. The real work was making sure years of posts, images, URLs, metadata, feeds, and odd WordPress behavior survived the move.
The design was not what held me back. Most of my content lives on a handful of key pages and in old blog posts. What made me put this off was the long-term indexing work I did not want to lose by casually flipping the site to a new stack.
For my site, the finished migration included:
- 15 published posts and 12 published pages converted to Markdown
- 44 public URLs audited and accounted for
- 39 URLs preserved exactly
- 5 old URLs redirected to a relevant replacement
- 54 media files downloaded and served locally
- Existing titles, descriptions, categories, tags, dates, and image alt text retained
- A generated sitemap, RSS feed, canonical tags, social metadata, and structured data
- A preview deployment and rollback plan before changing DNS
This is the process I used, including the parts I would not skip if organic search traffic matters to you.
Why I moved from WordPress to Astro
WordPress is still a good fit for many sites. It provides an editor, database, theme system, plugin ecosystem, user management, and years of accumulated answers for nearly every problem.
My personal site no longer needed most of that machinery.
I wanted a site that was:
- Fast by default
- Easy to version in Git
- Cheap and simple to host
- Made from files I control
- Comfortable to update with the same coding tools I already use
- Less dependent on a database, plugins, and server maintenance
- Easy for agents to work with (this was the biggest wish-list item)
Astro is designed for content-focused sites and generates static HTML by default. More importantly for me, its local-file workflow makes it easy to work on the site with agent tools. Cloudflare Pages gives me cheap, fast hosting and one path from a Git commit to a live site.
This does come with a tradeoff: I gave up the WordPress admin editor. New posts now live as Markdown files in the repository. That works for me because I already spend most of my time in Notion, Obsidian, GitHub, and Agent Canvas. Authoring, editing, and thinking in Markdown is just how I work now.
I also want to use the devinvinson.com domain for more than a portfolio. I want a personal development stack where I can deploy small projects quickly with agents, and moving the site into Cloudflare supports that longer-term plan. Astro felt like the right foundation for the personal site.
The migration stack
I kept the replacement deliberately boring:
- Astro for layouts, routes, content collections, sitemap generation, and the static build
- Markdown for posts and pages
- GitHub for source control
- Cloudflare Pages for previews and production hosting
- A small Node.js migration script for parsing the WordPress export, converting HTML, and downloading media
- A separate validator for checking URLs, metadata, assets, redirects, and generated output
There is no database, server-side rendering, client framework, or Cloudflare adapter on this site. Astro produces static files and Cloudflare serves the dist directory.
That last point is easy to overcomplicate. If your Astro site is fully static, you do not need the Cloudflare adapter. You only need it when you want Astro pages rendered on demand or need Cloudflare runtime features such as bindings.
Realistically, an agent can handle a big portion of this migration. I used agents for the tedious parts: inspecting the export, generating the conversion, comparing the local build to the live site, and finding the small things I would otherwise miss.
I would not give one a vague prompt, production credentials, and permission to change DNS. Give it the export, a clear definition of done, and a preview environment. Then review the output before it touches the live domain.
Step 1: Inventory the live WordPress site before changing it
The most important migration file was not the WordPress export. It was a spreadsheet of the URLs already online.
Before writing the Astro site, I crawled the live sitemap and recorded each public URL along with:
- HTTP status and final destination
- Page title and meta description
- Canonical URL
- H1
- Publication date
- Open Graph metadata
- Structured data types
- Content type
- Incoming internal links
I also checked behavior that was not obvious from the sitemap:
httptohttpswwwto the apex domain- WordPress author archives
- Category and tag pagination
- The legacy RSS URL
- The WordPress sitemap URL
- Numeric WordPress links such as
/?p=1550 - The 404 page
This inventory became the migration contract. Every old URL needed one of three outcomes:
- Keep the same URL.
- Permanently redirect it to the closest relevant page.
- Intentionally remove it and document why.
“I think we got everything” is not a useful validation strategy. A URL inventory gives you something concrete to compare against after the new site builds.
Step 2: Export WordPress content as WXR
In WordPress, I used Tools → Export → All content. That creates a WordPress eXtended RSS, or WXR, XML file containing posts, pages, attachments, authors, taxonomy terms, comments, and custom fields. The WordPress export documentation covers the dashboard steps.
The export is more complete than copying rendered pages, but it is not a ready-to-use Astro content directory. WordPress stores several values as namespaced XML fields, Gutenberg content is still HTML, and important SEO or featured-image values may live in post metadata.
In my export, I found 141 total records. Only 27 were published posts or pages. The rest included drafts, attachments, menu items, global styles, templates, template parts, form configuration, and other WordPress implementation data.
I explicitly filtered for published post and page records:
const published = items.filter((item) => {
const status = clean(item['wp:status']);
const type = clean(item['wp:post_type']);
return status === 'publish' && ['post', 'page'].includes(type);
});
Drafts were recorded in a reconciliation report instead of accidentally publishing them or silently discarding them.
Step 3: Convert WordPress HTML to Markdown
Each published WordPress item became one Markdown file in an Astro content collection:
src/
content/
blog/
deploying-fullstack-next-js-on-cloudflare-my-troubleshooting-guide.md
pages/
about.md
contact.md
I used fast-xml-parser to read the WXR file and turndown to convert the stored HTML to Markdown. A few custom conversion rules preserved figure captions and replaced iframe embeds with normal links.
The frontmatter retained more than the visible article:
---
title: "Deploying Next.js on Cloudflare: My Troubleshooting Guide"
description: "If you're running into deployment issues..."
pubDate: "2025-11-03 16:30:34"
updatedDate: "2025-11-24 19:29:02"
permalink: "/2025/11/deploying-fullstack-next-js-on-cloudflare-my-troubleshooting-guide/"
wordpressId: 6516
author: "devinvinson"
categories:
- name: "Blog"
slug: "blog"
seoTitle: "#post_title"
seoDescription: "If you're running into deployment issues..."
---
Astro content collections validate this data at build time. That caught missing fields and bad dates much earlier than discovering them on a production page.
I treated the automated conversion as a first pass. Tables, code blocks, figures, captions, embedded content, Gutenberg markup, and shortcodes all deserve a manual review. With a smaller site this was not too bad.
Step 4: Replace WordPress features intentionally
A WordPress export contains content and references to WordPress features. Astro cannot magically reproduce every plugin or shortcode.
My site had form shortcodes that depended on Formidable Forms. Carrying them into Markdown would only display broken text, so I replaced the forms with plaint text (no more forms, just find me on socials) instead of rebuilding forms right out of the gate.
That will not work for every site, so think through how you will port plugin-powered features before you start.
The right replacement may be an Astro component, an external service, a Cloudflare Function, or deciding that the feature is no longer useful. The important part is making the decision on purpose.
Or do what I did and remove them. I can always add features later. Getting off the old host and away from WordPress was more important to me than recreating every feature on day one, especially because I wanted better agent integration for my personal site and projects.
Step 5: Preserve permalinks whenever possible
My WordPress posts used date-based permalinks:
/2025/11/deploying-fullstack-next-js-on-cloudflare-my-troubleshooting-guide/
I kept that structure in Astro with a dynamic route:
src/pages/[year]/[month]/[slug].astro
The route reads each post’s saved permalink, extracts the year, month, and slug, and generates the matching static page. Pages, category archives, and tag archives follow the same principle.
Keeping a URL is safer and simpler than redirecting it. It preserves external links, browser history, analytics continuity, and the address Google already knows.
Do not change URLs just because a new framework makes another structure more convenient.
Step 6: Build a redirect map for everything else
Some WordPress-generated URLs did not need an equivalent page. Author archives and paginated archives, for example, could point to the main writing archive without losing useful content.
Cloudflare Pages reads redirect rules from a _redirects file placed in the public directory:
/feed/ /rss.xml 301
/sitemap.xml /sitemap-index.xml 301
/wp-sitemap.xml /sitemap-index.xml 301
/author/devinvinson/ /blog/ 301
/blog/page/2/ /blog/ 301
Each source should redirect directly to its final destination. Avoid chains such as old URL → temporary URL → current URL.
One Cloudflare-specific wrinkle: Pages _redirects rules match paths, not query strings. A legacy numeric WordPress URL such as /?p=1550 therefore needed a query-aware Cloudflare Redirect Rule instead.
Google recommends permanent server-side redirects for site moves and advises keeping redirects in place as long as possible, generally at least a year. Even when the domain does not change, the same discipline applies to any URL you replace.
Step 7: Download and rewrite every media URL
Leaving images on the old WordPress server would have made the “static” site quietly depend on the old hosting account.
I collected media URLs from both attachment records and links found inside post HTML. Each file was downloaded into a matching local path:
https://devinvinson.com/wp-content/uploads/2025/11/example.jpg
↓
public/media/2025/11/example.jpg
The Markdown content then referenced /media/2025/11/example.jpg.
For every image I also checked:
- The file exists locally
- The old WordPress host is no longer required
- Alt text and captions survived
- Width and height are present to reduce layout shifts
- Lazy loading is used where appropriate
- Featured images still render and have social metadata
The migration found 54 unique media URLs and downloaded all 54. That clean cutoff meant the WordPress server could eventually go away without breaking old posts.
Step 8: Rebuild the technical SEO WordPress used to generate
Removing an SEO plugin does not remove the need for SEO metadata. It means the new site must generate it somewhere else.
My Astro layouts now produce:
- A unique page title and meta description
- An absolute canonical URL
index,follow,max-image-preview:largeon normal pages- Open Graph and X/Twitter metadata
- Article published and modified times
BlogPosting,BreadcrumbList,Person,WebSite, and other relevant JSON-LD- An XML sitemap using Astro’s sitemap integration
- An RSS feed plus a redirect from the old feed URL
- A
robots.txtfile - A real custom 404 page marked
noindex,follow
The canonical domain is set once in astro.config.mjs:
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://devinvinson.com',
integrations: [sitemap()],
});
Setting site also gives Astro enough context to generate absolute sitemap URLs correctly.
I preserved existing SEO titles and descriptions where they existed, but normalized plugin template tokens such as #post_title. Migrating the raw token into the HTML title would have been technically valid code and obviously broken SEO.
Step 9: Deploy the static Astro site to Cloudflare Pages
The Cloudflare Pages setup for a static Astro site is incredibly easy (thankfully):
Production branch: main
Build command: npm run build
Build output directory: dist
The build script runs astro build. No environment variables or runtime adapter are required for this site.
That was noticeably simpler than deploying my full-stack Next.js app on Cloudflare, which needed separate OpenNext build, upload, and deploy commands. Static Astro only needed to produce a directory of files.
I also added a public/_headers file for basic security headers and long-lived caching on generated assets and local media:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Frame-Options: SAMEORIGIN
/media/*
Cache-Control: public, max-age=31536000, immutable
/_astro/*
Cache-Control: public, max-age=31536000, immutable
Cloudflare creates preview deployments for non-production branches and pull requests. I used the preview URL to test the new site before pointing the real domain at it.
Step 10: Test the built site, then bring in help
At this point I wanted another set of eyes. I used Agent Canvas and a couple of models through subagents to compare the local Astro build with the live WordPress site and report anything that looked wrong.
That process caught a few small fixes, but more importantly it made the testing less dependent on me. The checks covered the generated HTML, the old URL inventory, internal links, local assets, canonical URLs, titles and descriptions, sitemap entries, RSS, image attributes, and redirects.
With those fixes made, it looked ready to ship.
Step 11: Cut over the domain with a rollback path
I kept the old WordPress host available as a fallback, then went through the slow steps of configuring the final build in Cloudflare.
It should have been easy, but I always find the Cloudflare UI a little bit of a scavenger hunt.
My actual checklist was short:
- Deploy to a Cloudflare Pages preview URL.
- Test it one more time against the original URL list.
- Keep the existing DNS and WordPress settings recorded, just in case.
- Update the DNS in Cloudflare only when the preview looked right.
By this point I was tired of the process, so I used Cloudflare’s chatbot for the click-by-click directions. Not perfect but close enough.
Next Steps
The site is faster and completely managed through GitHub deployments now, which is a big win. It is too early to declare anything about search performance. I’ll keep watching indexing, crawl errors, redirects, and impressions over the next few weeks but so far so good.
WordPress to Astro migration checklist
If you are planning the same move, this is the short version:
- Crawl and inventory the live WordPress site.
- Export all content as WXR.
- Separate published content from drafts and implementation records.
- Convert posts and pages to validated Astro content collections.
- Review complex HTML, embeds, tables, code, captions, and shortcodes manually.
- Preserve existing URLs wherever possible.
- Map every changed URL to a direct permanent redirect.
- Download media and remove dependencies on the WordPress host.
- Recreate titles, descriptions, canonicals, social tags, and structured data.
- Generate and test the sitemap, RSS feed, robots file, and 404 page.
- Validate the built HTML and every old URL.
- Test a Cloudflare preview deployment before changing DNS.
- Keep WordPress available for a rollback window.
- Submit the sitemap and monitor Search Console after launch.
Astro’s official WordPress migration guide is a good starting point for the framework side, and Cloudflare has a current Astro deployment guide for Pages. The missing piece is usually the site-specific inventory and validation work between those two guides.
If I were doing this again, I would just export the content from WordPress and hand it to an agent tool with access to the repository and Cloudflare. Spend a few dollars on model credits and let it do the repetitive work—but keep a human review and the production cutover for the last personal step..
I tested that approach separately on another WordPress-to-Cloudflare Pages migration and had a preview URL live in under an hour. Live and learn.