Converting Adamfortuna.com from Next.js to Astro with a Headless WordPress CMS

This blog has undergone a lot of changes over the years. It was WordPress for many years, Jekyll, OctoPress, Middleman, and most recently Next.js with WordPress.
In 2024, I fell out of love with Next.js. I was a huge proponent of it for years. Hardcover is built on it (although we’re migrating to Ruby on Rails + Inertia.js) and I even started Nextjsbits.com, a blog about Next.js. I wrote a bunch of blog posts about Next.js, and I posted on the Next.js subreddit. In other words, I thought it was pretty great.
We switched to the Next.js App directory almost as soon as that was an option. I was so excited to use React Server Components that I waved away all the beta warnings and went full speed ahead. And it worked! Functionally, at least.
Slowly, problems started to sneak in. Our build time spiked, sometimes taking a minute locally for a route that should take a fraction of a second. Our Vercel bill grew from $20 a month to $500 due to the switch from client-side data loading to server-side loading. I (incorrectly) thought I understood how caching worked, only to later realize that nearly nothing was being cached – but even then I didn’t have insight into what was actually cached.
There were problems. The development experience was slower. The production experience was slower. Debugging it was slower. Expenses were higher.
All of these problems have solutions. They’re not inherent to Next.js, but they are (or at least were) difficult to avoid – even for someone who read the caching article on Next.js’s site at least 50 times.
I’ve since spent a bunch of time trying to understand what was wrong. Using turbo for local development might be possible with the latest version of Next.js – but by the time that was released, Hardcover’s migration was nearing the finish line.
By October of 2024 I was frustrated to the point I was looking for other options. Once the Hardcover rewrite is complete, I’ll share more about that process.
In November 2024, after the election, Vercel’s CEO, Guillermo Rauch, posted on X about his support of Trump. This was around the same time Sticker Mule’s CEO made similar statements.
Your attention and your spending are votes. While I know Next.js is a project with many amazing developers, Vercel is a business and the backing force behind Next.js.
I decided to start removing Next.js from my projects. I shut down Next.js Bits. I migrated Hardcover from Vercel to Google Cloud Run. Hardcover is being migrated away. And lastly, this blog is now running on Astro!
Migrating from Next.js to Astro
After seeing Astro on the yearly JavaScript survey with a high satisfaction score, it drew me in. When I realized their official documentation had a section on Headless WordPress & Astro, I knew it was a perfect fit.
The big difference between Next.js and Astro (or my specific implementation) is how pages are rendered. With Next.js, pages are all rendered when they’re first requested. For this blog, every page is rendered to static HTML at build time. That means it’s super fast!
There are downsides to this. When I write or edit a new post, it won’t be immediately available until a rebuild of the entire site is triggered. It also means that comments (using WordPress) aren’t an option. That’s something I still need to figure out. 🤔
When I started searching for “astro WordPress headless cms”, a wealth of videos came up! This also led me to this great Astro starter template.
I decided to start from scratch with npm create astro@latest and go from there.
Converting Next.js Pages to Astro
One of my first steps is making a list of the routes needed. There were a few more routes I haven’t moved over yet (ex: books read, photo posts).
/– Landing page for fun./projects– All the projects I’ve ever worked on, powered by WordPress./blog– List of all blog posts across all of my projects’ blogs./blog/projects– List of all projects./blog/projects/:project/:page– Paginated list of posts by project/blog/all/:page– Paginated list of posts./blog/tags– List of all tags./blog/tags/:tag/:page– Paginated list of posts by tag./newsletter– Form to sign up for my newsletter (submitted to Sendy)./newsletter/thanks– Redirect after signing up./:post– Single post or page corresponding with a WordPress URI.
That’s it! Not too many. I started converting everything on Tuesday, and by Saturday, everything was working!
The hardest part of the migration was moving React.js components into Astro components. Fortunately, only a few places require client-side code (namely, the homepage and the projects page).
Most of these are relatively simple like the Article component. Other more advanced components I kept as React (for now), like the Projects Listing component. Fetching data happens in Astro, then the Projects are passed to React for interactivity.
Fetching Data from WordPress
Fortunately, nothing changed on the WordPress side. I moved over all of my previous fetch GraphQL client, TypeScript types and all GraphQL queries.
I made the decision with this blog to list posts from adamfortuna.com, hardcover.app and minafi.com – three blogs I’ve spent a bunch of time on. However, only individual posts from adamfortuna.com are visible.
This means that when it comes to listing posts, or posts by tag, I need to grab posts from all three blogs and sort them by date.
The solution for this is to hit all three blogs, parse the results and sort them. The code for this is a bit hectic, but does the job. Most of these functions accept an array of projects to fetch from, that way if I want to add another blog later I can easily add it.
WordPress can allow GraphQL access without authentication, but certain fields won’t be available. The most important one is excerpt as regular text. I use this in the meta description and show it on the blog listing page for highlighted posts. That meant authentication would be needed.
I created tokens for each blog that represent the username and password, which are then set as environment variables.
Astro Pagination
My favorite part of the migration was using Astro’s pagination. Each paginated page is incredibly simple. The getStaticPaths method provides a paginate function which can be used to create multiple paginated pages from the same array of data. You don’t even need to slice the array!
Pagination with nested routes takes a little more work. For example, /blog/projects/:project/:page. This page needs to use the array of projects and gets all posts for each one then paginates them. The same technique is used on the Tag page.
Astro Workarounds
There were two parts I needed to change in order to work well with Astro and static generation.
The first was on the posts by tag page. When Astro tries to load the route /blog/tags/:tag/:page, it has to do a lot of work. It loads all tags, then it hits all three blogs to get all tags for each one. With 70 tags (so far), that means over 200 requests. This wouldn’t be a problem, but doing so using Promise.all meant DDOSing WordPress with 70 requests at a time. 😅
I switched to doing these in serial rather than parallel and it seemed to do the trick.
The next problem was that development was slow due to all of these API calls. This one was easy enough. I cached these saved values to a local variable and used those instead. If there’s a better way to do this with Astro, please point me in that direction.
The last major problem was updating Tailwind.css from version 3 to 4. The process went smoothly once I realized the @config ‘../../tailwind.config.js’ option in CSS file. Previously, I used the @screen md {} option in there to limit changed to @media(min-width: theme(--breakpoint-md)) { }.
Hosting
A few months ago I switched from Vercel to Netlify, and have been loving it. I haven’t tried it for anything big, but for my blog it’s been wonderful.
I set up Astro on there, and it worked on the first build!
What’s Next
This has been a fun project so far. There are a few things I’d still like to do next.
- ✅ Trigger a rebuild when something changes in WordPress
- Figure out what to do about Comments and Webmentions
- Add a book page showing what I’ve read or reading
- Add a movie page showing what I’ve watched.
- Same for TV shows.
- Add a list of hikes, maybe using something like this.
- Add back my photo posts. I’ve written a few in WordPress already.
- Add dark mode
- Make the homepage even more fun!
Updates!
Rather than writing a new post, I’m going to update this one as I tweak things.
Trigger Updates When WordPress Changes
It didn’t take long before I needed to fix a spelling error in this post. I don’t want to spend 15 minutes rebuilding the site to see those kinds of changes. Instead, I want them to be available as soon as possible!
The solution is to rely on Netlify for server-side generation (SSR).
That started with using the @astrojs/netlify library. While it’s possible to generate every post every time it’s accessed, I’d rather cache as much as possible for as long as possible.
After configuring Netlify with Astro, the next step was to set the right headers for each page.
// The browser should always check freshness
Astro.response.headers.set("cache-control", "public, max-age=0, must-revalidate");
// The CDN should cache for a year, but revalidate if the cache tag changes
Astro.response.headers.set("netlify-cdn-cache-control", "s-maxage=31536000");
// Tag the page with the book ID
Astro.response.headers.set("netlify-cache-tag", `post-id-${article.id}`);Code language: TypeScript (typescript)
The last one will use the dynamic post ID from the given post from WordPress.
To invalidate this, I needed to add a webhook that WordPress would hit, at src/pages/api/webhook.json.ts. This is the URL that’ll be hit whenever a new post is created or a post is updated.
export const prerender = false
import type { WordPressClientIdentifier } from "@/types";
import { purgeCache } from "@netlify/functions";
function sourceToProject(sourceUrl: string): WordPressClientIdentifier | null {
if(sourceUrl === "https://adamfortuna.com/") {
return "adamfortuna";
} else if(sourceUrl === "https://minafi.com/") {
return "minafi";
} else if(sourceUrl === "https://hardcover.com/") {
return "hardcover";
} else {
return null;
}
}
export async function POST({ request }: { request: Request }) {
try {
// See below for information on webhook security
if (request.headers.get("x-wordpress-webhook-secret") !== import.meta.env.WORDPRESS_WEBHOOK_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const body = await request.json();
const { post_id } = body;
if(!post_id) {
return new Response("No Post ID", { status: 401 });
}
const project = sourceToProject(request.headers.get("x-wp-webhook-source") || "");
if(!project) {
return new Response("No Project", { status: 401 });
}
const postTags = Object.keys(body.taxonomies?.post_tag || {});
const tags = [
`post-id-${post_id}`,
`blog/projects/${project}`,
'blog',
'blog/all',
...postTags
]
await purgeCache({
siteID: import.meta.env.NETLIFY_SITE_ID,
tags,
token: import.meta.env.NETLIFY_TOKEN
});
return new Response(`Revalidated entry with id ${post_id}`, { status: 200 });
} catch(e) {
return new Response(`Something went wrong: ${e}`, { status: 500 });
}
}Code language: TypeScript (typescript)
This ties in with all of the pages that could have this post – the main listing page, the all blog posts page, the posts by projects, and the tag pages. Each of these has their own cache key that corresponds with this callback.
To create the callback, I installed the WP Webhooks plugin, which works well for this. It also allows you to add custom headers to your requests. I added a x-wordpress-webhook-secret header that it sends over and I check for.
Next, I needed to grab my NETLIFY_SITE_ID from Netlify, and also create a personal token (NETLIFY_TOKEN). I added these locally and on production.
However, the caching I’d previously added conflicted with this. I disabled it, but then the initial build went too slow. The solution was even more simple: change routes that need dynamic generation to start with the line:
export const prerender = falseCode language: TypeScript (typescript)
With this, these pages won’t be generated when deployed but will be cached when rendered.
Side note: I went down another route where I attempted to build these at deploy time but then expire them with a webhook call. That didn’t work. I needed to add this prerender = false code in for it to work.
Let's keep in touch 🧑🤝🧑
- Send me an email at [email protected]
- Follow me on Bluesky at @adamfortuna.com
- Subscribe to my monthly newsletter
- Add my RSS feed to your favorite reader
Did you link to this article? Add it here and I'll include it