Migrating from WordPress to Astro: A Step-by-Step Guide
In short
A WordPress website can be moved to Astro in seven steps: inventory the URLs, export content to Markdown, settle the URL structure, build the redirects, carry over SEO data 1:1, replace forms and search, and launch under control. In the end the redirect table decides whether rankings survive: after the move, every old URL must answer with 200, 301 or, deliberately, 410. The hardest manual work is usually not the text but page-builder markup and shortcodes.
Moving a WordPress website to Astro is no sorcery, but it is not an export button either. What visitors and Google see in the end is decided in a few places: whether every old address still leads somewhere, whether titles and descriptions come along, and whether nothing important quietly disappears. This article walks you through the work in the order we would tackle it.
Whether the switch makes sense for you at all is covered in Astro vs. WordPress. Here we assume the decision is made and the question is how.
1. Inventory: what is there, and what matters?
Before you write a line of Astro, you need a complete list of what is reachable under your domain today.
Collect URLs. The list comes from several sources, because none is complete on its own:
- the XML sitemap (
/wp-sitemap.xmlwith WordPress core, otherwise the one from your SEO plugin), - a crawl with a tool of your choice that also follows internal links (this finds pages that are in no sitemap),
- the export of pages with impressions and clicks from Search Console.
The result is a table: one row per URL, next to it the page type (page, post, category, attachment, archive), clicks, impressions and the number of incoming links. You get the latter from the Links report in Search Console or from a backlink tool.
Weigh them. Not every URL is equally important. Pages with traffic and backlinks must live on seamlessly. Outdated posts with no traffic and no links may be merged or removed, but deliberately and with a target in the table, not by forgetting.
List plugins by function. The plugin’s name does not matter; what matters is what it does on the website. For each plugin there are three possible answers: the function goes away, Astro can do it natively, or it needs a replacement. Typical candidates:
| Function under WordPress | Counterpart in Astro |
|---|---|
| SEO plugin (title, description, schema, sitemap) | frontmatter fields, layout component, @astrojs/sitemap |
| Caching and optimization plugins | not needed, Astro outputs finished HTML |
| Form plugin | own endpoint or a service, see step 6 |
| Shortcodes (buttons, accordions, galleries) | Astro components or Markdown |
| Multilingual (WPML, Polylang) | folder structure and routing, see step 3 |
| Search, comments, member area | replacement needed or drop deliberately |
A WooCommerce shop is a case of its own: cart and checkout gain nothing from a static website. That is what the headless route is for, described in Headless WordPress with Astro.
2. Export content: Markdown with frontmatter
The goal: every page and post becomes a Markdown file with frontmatter for title, description, date and whatever else the page needs. There are two ways to get there.
The WXR export (Tools, Export) gives you an XML file with everything. Handy for a one-off sweep, but you have to parse it yourself.
The REST API delivers the same as JSON and is easier to script. Every WordPress installation ships with it; posts live at /wp-json/wp/v2/posts, pages at /wp-json/wp/v2/pages. A request returns at most 100 entries, so you page through:
// export.mjs – write posts out as Markdown files
import { writeFile, mkdir } from 'node:fs/promises';
import TurndownService from 'turndown';
const base = 'https://example.com/wp-json/wp/v2';
const turndown = new TurndownService({ headingStyle: 'atx' });
await mkdir('src/content/blog', { recursive: true });
let page = 1;
let totalPages = 1;
do {
const res = await fetch(`${base}/posts?per_page=100&page=${page}`);
totalPages = Number(res.headers.get('X-WP-TotalPages'));
for (const post of await res.json()) {
const body = turndown.turndown(post.content.rendered);
const frontmatter = [
'---',
`title: ${JSON.stringify(post.title.rendered)}`,
`description: ${JSON.stringify(post.excerpt.rendered.replace(/<[^>]+>/g, '').trim())}`,
`pubDate: ${post.date.slice(0, 10)}`,
`wpId: ${post.id}`,
'---',
].join('\n');
await writeFile(`src/content/blog/${post.slug}.md`, `${frontmatter}\n\n${body}\n`);
}
page++;
} while (page <= totalPages);
The script is a starting point, not a finished tool. Titles contain HTML entities, excerpts rarely make good meta descriptions, and if you use an SEO plugin, the real meta titles and descriptions live in its fields (Yoast, for example, exposes them in the REST response under yoast_head_json). More on that in step 5.
Images need a pass of their own. The script collects all addresses from /wp-content/uploads/, downloads the files and rewrites the paths in the Markdown. Images used in body text go into src/assets/ so Astro can optimize them. Files that must stay reachable at the old address go into public/, more on that in step 4. Don’t forget the alt texts: they live in the media library, not in the post text.
Shortcodes and page-builder markup are the hard part. [button], [gallery] and above all the nested containers of Elementor, Divi or WPBakery cannot be converted automatically in any meaningful way. What remains is a mix of rules and hand work:
- Count which shortcodes and blocks occur at all, and sort by frequency.
- Build an Astro component for the frequent ones and translate them with a script, for example
[button url="…"]Text[/button]into<Button href="…">Text</Button>. For that you need MDX. - Rare special cases, especially individually designed landing pages, you rebuild as their own
.astropage instead of reconstructing them from builder markup.
Content collections with a schema. So that incomplete content breaks the build instead of going live unnoticed, you describe the frontmatter in src/content.config.ts with a zod schema (source: Astro documentation, “Content collections”):
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string().min(10).max(70),
description: z.string().min(120).max(160),
pubDate: z.coerce.date(),
wpId: z.number().optional(),
}),
});
export const collections = { blog };
If a file violates the schema, Astro reports it with an error message. With an export of hundreds of files this is gold: you see right away which descriptions are missing or too long.
3. URL structure: keep addresses where you can
The best redirect is the one you don’t need. If a URL stays the same, nothing changes for Google. So first check what you can carry over 1:1:
- Trailing slash. WordPress permalinks usually end in
/. SettrailingSlash: 'always'inastro.config.mjsso Astro only knows that form. According to the documentation, the default is'ignore'. - Output format.
build.formatdefaults to'directory':/about-us/becomesabout-us/index.html, matching WordPress-style addresses. Only if you must keep addresses ending in.htmldo you use'file'. - Categories and pagination. If your posts had URLs like
/category/seo/or/blog/page/2/, you create matching routes withgetStaticPaths()andpaginate(). Whether the structure stays or gets simpler is a design decision; what matters is that every old address ends up in the table from step 4. - Slugs. The export carries the WordPress slug, the last URL segment. Use it unchanged as the file name.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
trailingSlash: 'always',
build: { format: 'directory' },
integrations: [sitemap()],
});
If the website is multilingual, plan the structure now, not later. How that looks for a static site is described in Multilingual Astro: Two Domains, Two Languages, One Build.
4. Redirects: this is where rankings are decided
The redirect table is the most important document of the migration. Every URL in your inventory gets a target: same address (no entry needed), new address (301) or deliberately gone (410). Nothing that used to have visitors may end in a 404.
301 from the server, not from Astro. Astro has a redirects option in its configuration:
export default defineConfig({
redirects: {
'/category/seo/': '/blog/seo/',
'/old-post/': '/blog/new-post/',
},
});
It is convenient, but on static hosting it has a limitation you need to know about. According to the Astro documentation, astro build outputs HTML files with a meta refresh tag for static sites; the status code is then not used by the server. So the server answers with 200 and the redirect only happens in the browser. That is not a real 301. If you want to protect rankings, put the rules into the server configuration. On Apache that is the .htaccess:
# Single pages
Redirect 301 /old-post/ /blog/new-post/
# Whole prefix
RedirectMatch 301 ^/category/(.*)$ /blog/$1
and on nginx the same looks like this:
location = /old-post/ { return 301 /blog/new-post/; }
rewrite ^/category/(.*)$ /blog/$1 permanent;
For more than a handful of entries, our Redirect Generator turns a list of old and new URLs into ready-made rules for Apache or nginx.
WordPress leftovers. A few addresses are not in your inventory because WordPress created them automatically. They still show up in links and in the index:
| Old address | Recommendation |
|---|---|
/feed/, /comments/feed/ |
301 to a new feed or the post overview, otherwise 410 |
/?p=123 (short links) |
301 to the post’s new address |
/wp-admin/, /wp-login.php |
410 or simply 404; they belong to no content |
/wp-content/uploads/… |
keep serving the file or 301 to the new file if linked externally (images, PDFs) |
/author/…, /2023/05/, /tag/… |
301 to the matching overview, or 410 if the archives go away |
/category/… |
301 to the new category or the overview |
The ?p= short links need a condition on the query string, because Redirect does not evaluate it:
RewriteEngine On
RewriteCond %{QUERY_STRING} ^p=123$
RewriteRule ^$ /blog/new-post/? [R=301,L]
The trailing ? stops the query string from sticking to the target. For deleted areas use Redirect gone /wp-admin/ (Apache) or return 410; (nginx).
For uploads: if other sites still hotlink your images, copy the originals to public/wp-content/uploads/ and leave them there. That is the simplest redirect. Astro assigns new file names to optimized images, so redirecting only works for files you serve unchanged.
410 or 301? A 410 (“Gone”) clearly says something was removed on purpose. A 301 belongs only where the target fits in substance. Sending all dead pages to the homepage is no substitute: Google may treat that as a soft 404 (note from the Google Search Central documentation).
5. Carry over SEO data
The rule of thumb: whatever Google sees today should look the same tomorrow.
- Titles and descriptions 1:1. The meta data usually lives in your SEO plugin’s field, not in the post title. Export it too and write it into the frontmatter. Don’t reword ranking titles, not even for taste. You can improve them later, one at a time and measurably.
- Canonical. Every page gets a self-referencing canonical on its final address, with trailing slash and the right domain.
- hreflang for multiple languages, reciprocal and with absolute URLs.
- Structured data. Organization, breadcrumbs, article and FAQ that your plugin generated, you rebuild as JSON-LD in the layout component. Afterwards, check them with the Rich Results Test. What Astro brings out of the box for SEO and what it doesn’t is covered in Astro SEO.
- Sitemap.
@astrojs/sitemapgenerates it at build time but needs thesiteoption. Leave out pages that should not be indexed. - robots.txt. As a file in
public/, pointing to the new sitemap. Check that the staging environment’sDisallow: /is not still in there. - 404 page.
src/pages/404.astrobecomes404.htmlat build time; on Apache you register it withErrorDocument 404 /404.html. A good 404 has navigation, not a dead end. - Internal links. The export contains absolute links to the old address and to
/wp-content/. Replace them with relative paths, otherwise every internal click goes through a redirect.
6. Forms, search and comments
A static website has no database and no PHP, so dynamic features need a counterpart.
Forms. Two clean routes: a small endpoint or a service. If the website runs on hosting with PHP anyway, a short script that validates input and sends by SMTP is enough (we use PHPMailer on our own website). Spam protection, required fields and an error message on failure are part of it. Test sending regularly, because a broken form goes unnoticed. Alternatively, use a form service; then check where the data is stored and whether a data processing agreement is possible.
Search. For websites up to a certain size, a static search that builds an index after the build is enough, for example Pagefind. It needs no server. For very large collections or many filters, a search service pays off.
Comments. An honest tally helps here: how many comments were there in the last twelve months? Usually the answer is “hardly any”, and then dropping them is the best solution. You can archive existing comments as text if they carry content. If you need discussion, embed an external system, with privacy in mind.
Member areas, bookings, shops. Anything with login and state is not a case for a purely static site. There, WordPress or another system stays in the background and Astro handles delivery, as described in Headless WordPress with Astro.
7. Launch and after
Before the move. Build the new website on a staging address with noindex and test it against your URL list. A small script is enough: it requests every old URL and prints status code and target:
while read -r url; do
printf '%s -> ' "$url"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$url"
done < old-urls.txt
Every line must show 200, 301 (with the right target) or, deliberately, 410. Anything else is an error you want to fix before launch. Also make sure the staging noindex and the robots.txt block don’t go live.
Launch checklist:
- Publish the new website with redirects active.
- Run the script above against the live address.
- Spot checks: homepage, the ten most important pages by traffic, category and pagination pages, an image from
/wp-content/uploads/. - Submit the form and actually see the email arrive.
- Submit the sitemap in Search Console.
- If the domain changes, additionally use the Change of Address tool in Search Console (Google Search Central, documentation on site moves). If the domain stays the same, skip this.
- Check tracking: is the new website being measured?
After launch. In the first days, look at the page indexing report and the 404 reports in Search Console. Every 404 URL that used to have traffic gets an entry in the redirect table. Compare traffic weekly for four to eight weeks against the state before the move. Slight fluctuations are normal because Google has to re-evaluate the new version. If a group of pages drops clearly, look there first: is a redirect missing, did a title change, is a canonical wrong?
Keep the redirects in place, ideally permanently. In its documentation on site moves, Google recommends keeping them for at least a year.
What drives the effort
There is no one-size-fits-all answer, but there are clear drivers:
- Page count: more pages mean more checking and more redirects, even if the export runs automatically.
- Page builder: the more layout lives in builder markup, the more pages you have to rebuild instead of convert.
- Plugins with a visible function: each one needs a counterpart or a decision.
- Multilingual: every language doubles inventory, redirects and checks, and hreflang comes on top.
- State of the existing content: old posts with broken links, missing images and duplicates cost time you can’t see beforehand.
If you count beforehand how many pages, shortcodes, plugins and languages are involved, you can estimate the effort realistically. The inventory from step 1 is therefore also the basis of any calculation. How this adds up in a full relaunch is shown in Website Relaunch: Cost, Process, and the Priciest SEO Traps.
If you’d rather hand it off
You can go through all of these steps yourself. If you lack the time or the appetite, we take over from the inventory to the checks after launch. How we go about it is described on the page WordPress to Astro migration.
Frequently Asked Questions
Can I migrate from WordPress to Astro myself?
Yes, if the website is manageable and you are comfortable with Node.js, Git and a config file. Exporting the content with the WordPress REST API is quite doable. It gets laborious with page builders, many plugins, several languages and large URL sets, because hand work and checking grow there.
Will I lose my rankings when moving from WordPress to Astro?
Not necessarily. If URLs stay the same or are cleanly redirected with a 301, and titles, descriptions, canonicals and structured data are carried over, Google keeps the association. Short fluctuations after launch are normal; there is no guarantee.
Are the redirects in the Astro config enough?
Not on static hosting. There, Astro generates a small HTML page with a meta refresh for each redirect, but the server answers with 200 instead of 301. For real 301s the rules belong in the server configuration, such as .htaccess or nginx.
How do I handle /feed/, /wp-admin/ and author archives?
Anything with a counterpart gets a 301 to the matching new page, for example /feed/ to the overview or a new feed. What will no longer exist, such as /wp-admin/ or date and author archives, you answer with 410 or redirect to the closest overview.
How long should I monitor after launch?
At least four to eight weeks. In Search Console you watch indexing and 404 errors, and you compare traffic weekly against the state before the move. Keep the redirects in place for much longer.
Related Services
Founder & Full-Stack Developer
20+ years of web development experience. Specialised in Laravel, WordPress and custom software for mid-sized businesses.