Migrating from Next.js to Astro

15 min read

Migrating from Next.js to Astro

Intro

I was very excited when I set out to build this site in 2021 and discovered Next.js. I’ve been “spinning up” websites since the late 90s, and I’m still amazed at how far things have come. Next.js + Vercel allowed me to build an interface in React and deploy it relatively easily. Now, I’m not saying what Next.js + Vercel were, and still are, doing are novel, as I’m aware there are similar frameworks and “one-click” hosting providers out there. (I remember when Dreamhost launched their one-click WordPress install). However, I was excited and motivated to build a site from scratch and put my name behind it.

Since then, I’ve made various changes and improvements to the site and kept pushing myself to find things to write about. I even upgraded to Next.js version 13 and opted into the app directory beta a month ago. Since upgrading, I struggled with certain things and even considered moving off the app beta and back to the pages directory.

That was until I was casually browsing either r/webdev or r/javascript and saw someone in the comments mention Astro. I have yet to go back to look for the exact post. Maybe I should, so I can thank that person.

As of two weeks ago, this site is entirely off Next.js and instead built with Astro. Even more, I completely dropped React and went with native Astro components and vanilla JS. Now that the dust has settled, I thought I’d write about why I switched, what I like, what I don’t like, and give some insight into what the migration looked like.

Why?

Why did I dedicate the time and effort to learning an entirely new framework and migrating this site? Well…

Why Not?

Part of the reason I built this site was to try something new and have a project that allowed me to write a fair bit of code. While I worked as a front-end developer for many years, I’ve worked as an engineering manager since 2014. With that, I don’t have the opportunities I once had to flex my coding muscles. This being my personal site, gives me the freedom to play with the new shiny toys as I discover them. That’s not to say I’m ready and willing to jump into any new framework or library I come across. I did a bit of reading through Astro’s docs and set up a demo app before fully committing to migrating over.

Performance

While Next.js does a great job of bridging the gap between full-blown SPAs and static websites, there is still a fair bit of client-side rendering that happens with any site built on Next.js. With support for React Server Components coming down the pipe and the other work in progress in the app beta around static rendering, Next.js will continue to improve.

However, the needs of this site are free of any server-side-rendering whatsoever. The only dynamic section of this site is the post comments, and I use a third-party FastComments for that. While Astro can do many things that Next.js can, Astro is built more for static websites. The Why Astro? docs call this out in a few places.

Astro was designed for building content-rich websites.

If your project falls into the second “application” camp, Astro might not be the right choice for your project… and that’s okay! Check out Next.js for a more application-focused alternative to Astro.

Astro leverages server-side rendering over client-side rendering as much as possible.

Not React Dependent

Next.js being “The React Framework” was a big selling point for me as React was and still is, the library I’m most familiar with and comfortable with. With Astro, React is optional. You can use any combination of UI frameworks, including React, Preact, Svelte, Vue, SolidJS, AlpineJS, and Lit.

While migrating, I had a goal in the back of my mind to get the first version out without using any React code. To my surprise, I was able to do just that. Reaching for vanilla JS to do things more commonly done these days in React was quite refreshing.

More Aligned Feature Support

I discovered Astro right after version 2 was released. Had I found it prior, I’m not sure I would have considered it as seriously as I did.

Content Collections Instead of dealing with the filesystem directly to read, sort, and process Markdown files like I was in Next.js, Astro adds a layer of utils that does all that for you. My biggest seller was the built-in support for content collections. I’ll show code comparisons below.

RSS + Sitemaps There are plugins for both things in Next.js, but they always felt a bit clunky to use. The Astro team built and maintains packages for adding RSS feeds and sitemaps, which were straightforward to set up and start using.

These features aren’t game changers, but the fact that they were thought about and existed as part of the core Astro library solidified my reasoning that Astro is more aligned with what I need for this site.

Vercel

Supporting Vercel deployments out of the box made the decision that much easier. Moving to the other providers Astro supports would have been fine, but I’ve been pleased with everything Vercel offers. Knowing that I didn’t have to think about a new deploy process and hosting provider was icing on the cake.

Code Comparisons

Markdown/MDX Support

In Next.js, I needed to write all my own utility functions to read, sort, and process Markdown files. Here is an abbreviated look into some of the files required to load up the posts on this site.

lib/files.js
import fs from 'fs';
import { fileURLToPath } from 'url';
import { join, dirname } from 'path';
export const getPath = (file) =>
join(dirname(fileURLToPath(import.meta.url)), '../', file);
export const getFilesInDirectory = (directory) =>
fs.readdirSync(getPath(directory));
lib/markdown.js
import matter from 'gray-matter';
import { getFilesInDirectory, loadFile } from 'lib/files';
const MD_EXT_REGEXP = /\.mdx?$/;
export const getMarkdownFilesInDirectory = (directory) => {
return getFilesInDirectory(directory).filter((file) =>
file.match(MD_EXT_REGEXP)
);
};
export const getSlugForFileName = (filename) => {
return filename.split('/').slice(-1).pop().replace(MD_EXT_REGEXP, '');
};
export const getMarkdownData = async (filePath, fields = []) => {
const fileContents = loadFile(filePath);
const { data, content } = matter(fileContents);
...
if (fields.includes('content') || getAll) {
markdownData.content = await convertMarkdown(content);
}
return markdownData;
};

Since Astro includes utilities for querying collections, there’s no need to do any of the stuff from above. It’s as simple as importing the astro:content package and calling getCollection with the collection type.

utils/collections.js
import { getCollection } from 'astro:content';
const isDev = import.meta.env.MODE === 'development';
export const getPosts = async (includeDrafts = isDev) => {
const draftFilter = !includeDrafts ? (item) => !item.data.draft : null;
const items = await getCollection('posts', draftFilter);
return items;
};

I appreciate the thought that went into building this layer of abstraction around Markdown in the context of blog posts and other content types.

JSX Style Syntax

Coming from React, Astro components have a familiar feel because of the JSX-like expressions. Vue and Svelte users may find it even more familiar with the in-component <style> and <script> tags.

Let’s look at a simple component in React and Astro.

components/DateFormat.jsx
import { isDate, parseISO, format } from 'date-fns';
const DateFormat = ({ date, formatStr = 'LLLL d, yyyy', ...props }) => {
const dateToFormat = isDate(date) ? date : parseISO(date);
return (
<time dateTime={format(dateToFormat, 'yyyy-MM-dd')} {...props}>
{format(dateToFormat, formatStr)}
</time>
);
};
export default DateFormat;
components/DateFormat.astro
---
import { isDate, parseISO, format } from 'date-fns';
const { date, formatStr = 'LLLL d, yyyy', ...props } = Astro.props;
const dateToFormat = isDate(date) ? date : parseISO(date);
---
<time datetime={format(dateToFormat, 'yyyy-MM-dd')} {...props}>
{format(dateToFormat, formatStr)}
</time>

All the business logic needed to render the component happens between frontmatter style code fences ---. All this code runs at build-time and is passed to the component to render HTML.

There are more examples of what else you can and can’t do in the Astro docs. Converting the ~25 components used for this site was one of the easier parts of the overall migration.

Client Side Events

If there was one time during the migration I started itching for React it was when I needed to port over the handful of components that did have some client interactivity. A simple example of this is the Back To Top link.

In React, you set the button’s onClick prop to a function that calls window.scrollTo().

components/BackToTop.jsx
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLevelUpAlt } from '@fortawesome/free-solid-svg-icons';
const BackToTop = ({ className = '', ...props }) => {
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<div className={className}>
<button
type="button"
className="btn btn-link"
onClick={scrollToTop}
aria-label="Back to top"
{...props}
>
Back to top <FontAwesomeIcon icon={faLevelUpAlt} />
</button>
</div>
);
};
export default BackToTop;

With native Astro components, you must interact directly with the DOM to find and bind a click event to the button. If you include the onClick prop on the <button> element in the React component, the below is technically two additional lines of code. However, using Astro saves you all the overhead of React in asset size and initial load time.

components/BackToTop.astro
---
import Icon from '@/components/Icon.astro';
const { class: className = '', ...props } = Astro.props;
---
<button
type="button"
class:list={[
'btn btn-link btn-back-to-top d-none',
className,
]}
aria-label="Back to top"
{...props}
>
Back to top <Icon icon="fa-solid:level-up-alt" />
</button>
<script>
const backToTop = document.querySelectorAll('.btn-back-to-top');
backToTop.forEach((button) => {
button.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
});
</script>

Little Things

Anyone who’s ever written code in React is familiar with either the classnames or clsx libraries. Well, Astro has its own built-in way of dealing with conditional classes.

Using clasnames in React.

components/DraftBadge.jsx
import classNames from 'classnames';
const DraftBadge = ({ className = '', ...props }) => {
return (
<span className={classNames(
'p-1 rounded-1 bg-info small',
className
)}>
Draft
</span>
);
};
export default DraftBadge;

Using class:list directive in Astro.

components/DraftBadge.astro
---
const { class: className = '' } = Astro.props;
---
<span class:list={[
'p-1 rounded-1 bg-info small',
className
]}>
Draft
</span>

Little things like this show that the core team is in tune with the patterns the developers using Astro are familiar with.

Benchmarks

It’s not fair to compare Next.js to Astro since there are fundamental differences in how they build, and I’ve removed React from this site. Since the migration, I’ve also made visual changes to the site. However, there is still value in seeing the differences.

I used Chrome’s Dev Tools and various performance-testing websites for these tests. I ran my Using Custom Icon SVGs with Font Awesome’s React Component post through the tests since it’s the only post with both images and code blocks. I keep the last deploy of the Next.js version of the site up in a Vercel preview environment that I used for comparison.

Next.jsAstroReduction
Vercel Deploy Time (10 deploys)36.8 s33.2 s9.8%
JavaScript Assets191 kB3.9 kB98%
JavaScript Requests14471%
JavaScript Load Completion1.05 s424 ms60%
Time to Interactive (Desktop)0.4 s0.2 s50%
Time to Interactive (Mobile)3.5 s1.4 s60%

Needs Improvement

I’ve been doing an excellent job of talking Astro up so far. While I’ve been pleased with Astro, some things can (and will) be improved. Astro is still young. At the time of writing, Astro is only a year and a half old. The first release on GitHub, astro@0.18.9 was published on August 5, 2021. The fact that they’ve already gained momentum is a testament to the time and effort the core team and community has put into the project.

So take the below with a grain of salt. With time, the team and community will improve these things.

Images

Astro ships with its own <Image /> component in @astrojs/image the other popular option is the community-developed astro-imagetools. These integrations offer ways of loading images into components and optimizing those images during build-time.

On the surface, either of these would be robust enough for most use cases. However, I’ve found that that isn’t exactly the case. Astro’s docs go so far as calling their integration experimental.

⚠️ This integration is still experimental! Only node environments are supported currently, stay tuned for Deno support in the future!

I bounced from Astro’s integration to Astro ImageTools and then back to Astro’s integration.

Luckily, there is already an RFC for removing the @astrojs/image integration and improving the core image handling. The first line of the Background section in the RFC sums it up.

Using images in Astro is currently a bit confusing.

I’ll be following along. In the meantime, I’ve put together a hacky set of utils that gets me where I need to be. You can view that file here.

Dev Environment

It seems that the upgrade to 2.0 did wonders for the local dev environment. Since I have no history with Astro 1.x it’s hard to compare, but their 2.0 announcement post specifically calls out better error overlays, improved HMR (Hot Module Reloading), and a move to Vite 4.0.

Working locally has been an enjoyable experience, but there have been some headaches. I’ve noticed some errors being thrown that were just plain wrong, especially when dealing with MDX. I’ve also found specific workflows where HMR doesn’t work, and a full stop and start of the astro dev command is needed.

Ecosystem

With Astro only existing for about a year and a half, it’s very understandable that the ecosystem is smaller than other more mature and established libraries and frameworks like Next.js. However, a few dozen integrations exist and are showcased on the main Astro website. The ecosystem will grow over time. I even plan on releasing an integration of my own in the coming weeks.

What’s Next? (No pun intended)

Now that I’ve had time to reflect on my initial experience with Astro, I started thinking about what else I wanted to do or improve. While migrating, two things became abundantly clear as things I should work on next.

TypeScript

I’ve been holding out on fully committing to TypeScript for too long. Astro ships with built-in support for TypeScript, so converting this site as my first go makes the most sense. I need to rip off the bandaid and do it. Honestly though, my love for Sublime Text is the biggest thing holding me back from using TS. VS Code does a much better job of dealing with TypeScript, and I’m just not ready to give up Sublime.

If anyone knows of an ideal setup for using TypeScript within Sublime Text or has some resources for Sublime Text holdouts, like me, moving to VS Code, please let me know in the comments.

Create Integrations

As I mentioned above, I plan to release at least one integration for the community. The first is an integration for creating custom syntax highlighting components in Markdown files. Astro supports Shiki and Prism out of the box, with little to no configuration. But if you wanted to, let’s say, wrap the output of those highlighters in your own component to add things like displaying the filename, displaying the code language, and adding a “copy code” button, your options are pretty limited.

I had to pull down Astro’s documentation repo to see how they were doing it on their Docs site. Then I developed my own remark plugin for this site.

Whenever Astro’s mdx integration picks up on a fenced code block (```) in my MDX files, the content is parsed, formatted, then sent to my custom <CodeBlock> component as props where I can render the syntax highlighting however I want.

components/CodeBlock.astro
---
import { getCodeFromSlot } from '@/utils/remark/code-blocks';
import { Prism } from '@astrojs/prism';
import CopyToClipboard from '@/components/CopyToClipboard.astro';
import '@/styles/code-block.scss';
const code = await getCodeFromSlot(Astro.slots);
const { style, lang, filename = '' } = Astro.props;
---
<figure
class="code-block rounded-1 border mb-3"
style={style}
>
{!!filename && (
<figcaption class="py-2 px-4 border-bottom overflow-auto text-body-secondary font-monospace">
{filename}
</figcaption>
)}
<div class="position-relative p-4 pb-0">
{!!lang && (
<div class="code-language position-absolute top-0 end-0 px-2 py-1 font-monospace border-bottom-start">
{lang}
</div>
)}
<Prism
code={code}
lang={lang}
class="m-0 p-0 pb-4"
/>
<CopyToClipboard
class="position-absolute end-0 me-1"
value={code}
/>
</div>
</figure>

Conclusion

I’m super happy that Astro exists. For the needs of this site, it’s a perfect fit. It’s great to see people continue thinking up and putting projects like this into the world, even with more established players like Next.js and Remix gaining momentum.

It’s also fun to see things come around almost full circle to the earlier days of web development, where one of the norms was to build static HTML files locally and FTP them onto a server somewhere. Over the past ten or so years, the front-end community has definitely indexed more towards building sites with tools like React that do their rendering on the client. It’s nice to see the pendulum swing back in the other direction but with modern tools to support it.

Found the above useful? Have a question? Thought of something I didn't?

Consider leaving a comment below or send me an email at johnzanussi@gmail.com.

You can also buy me a coffee. Cheers!

Buy Me A Coffee

Comments