Converting Next.js to Astro

After first Converting Next.js to Qwik then cutting short my attempt at Converting Next.js to Remix, I decided to give Astro another shot, now that it’s 1.0.

Updated on 2023-01-24

Astro 2.0 has been released and there are small changes that I had to make. See this PR for more details: Upgrade to Astro 2.0

(I learned about Astro’s Migrating to Next.js guide after the initial post 🤦‍♂️)


Friction Log

Official Integrations

These were the first thing I consulted to make sure migrating a “brownfield” project would have reduced friction.

Eventually I settled on installing the following with 1 command:

pnpm astro add react vercel sitemap mdx tailwind image

The fact that Astro shows you what changed is a great DX. Most CLI’s will modify package.json & configs, but it’s up to you to recognize the changes the next time you run git add -p.


I admit, I’m tired of <script dangerouslySetInnerHtml> in React & Next.js, in particular.

Attempting to set content creates hydration issues between the server & client (usually due to an HTML entity encoding issue), loss of syntax highlighting, and other friction.

Astro, instead, treats script tags the way you’d want. This just works:

  console.log('Welcome, browser console!')
  • Astro process the <script>, so imports and dependencies are bundled
  • <script is:inline> skips the processing


With the VS Code extension installed, I expected my usual format-on-save to work, but it didn’t. I forgot I had to create prettier.config.js:

module.exports = {
  singleQuote: true,
  semi: false,
  plugins: [require('prettier-plugin-tailwindcss')],


When I’m missing a dependency, such as clsx, the build predictably fails. But, if I install it in another terminal window, Astro rebuilds the page without anything else from me!

<Image /> doesn’t work within React components, only .astro components

Improve error handling for @astrojs/image within react components


Install Astro’s RSS package, and this is pretty much ready to go!

class:list instead of clsx

When setting className conditionally, clsx is a common approach and is used by Tailwind UI.

Personally, I prefer doing the following:

    disabled ? 'opacity-50' : false,

Content Collections

Where I struggled with Qwik was getting a list of all the content on the file system.

Astro has a guide on Importing Markdown that amounts to importing a file specifically or Astro.glob('*.md') (my preference).

However, I’m interested in trying Astro’s Content Collections (Experimental) so that frontmatter is strictly enforced, there can be different subsections of content, and content is separate from the theme.

A couple of things immediately impressed me!

First, it’s a cinch to fetch & render data automatically:

import { getCollection, getEntry } from "astro:content"
import Post from "@/components/Post.astro"

const posts = await getCollection('blog')
const post = await getEntry('blog', 'javascript-fatigue.mdx')
const { Content } = post.render();
--- => <Post post={post} />)

<Content />

Second, getCollection is automatically typed with the actual data. This means that TypeScript knows what posts exist, what their slugs are, and all of their metadata.

This caught errors in my frontmatter where I was incorrectly specifying dates!

import { z, defineCollection } from 'astro:content'

const blog = defineCollection({
  schema: {
    summary: z.string().optional(),
    tags: z.array(z.string()).default([]),
    title: z.string(),

export const collections = { blog }

After much usage, my biggest complaint is that getEntry isn’t equivalent to a faster, single function for getCollection('blog').find(({ slug }) => Astro.params.slug ).

You have to know the extension the file, which can be .md or .mdx.

I’m using .mdx everywhere, so getEntry('blog', 'javascript-fatigue.mdx') isn’t much a problem, but it feels like there should be a direct equivalent between URL’s slug & the equivalent on the file system without having TypeScript errors:

Argument of type '`${string}.mdx`' is not assignable to parameter of type '"3-ways-to-define-webpack-loaders.mdx" | "advent-of-javascript/day-1.mdx" | "advent-of-javascript/day-10.mdx" | "advent-of-javascript/day-17.mdx" | "advent-of-javascript/day-2.mdx" | ... 29 more ... | "writing-paralysis.mdx"'.

A “gotcha” is that Custom components with imported MDX only work with .mdx content, not .md.

I expected that at least h1, h2, p, etc. would work (not actually components) with <Content components={...} />, but no.

Converting from React to .astro

What I’ve found is that presentational (read: stateless) components are pretty straightforward to convert.

For example, here’s a simple React component:

import { Card } from '@/components/Card'

function Appearance({ title, description, event, cta, href }) {
  return (
    <Card as="article">
      <Card.Title as="h3" href={href}>
      <Card.Eyebrow decorate>{event}</Card.Eyebrow>

Generally, the steps are:

  1. Separate the code from the template with ---
  2. Remove return (...)
  3. Pull destructured props from Astro.props
  4. Replace {children} with <slot />
  5. Rename className to class on HTML elements (but not Components!)
import { Card } from '@/components/Card'
const { title, description, event, cta, href } = Astro.props
<Card as="article">
  <Card.Title as="h3" href={href}>
  <Card.Eyebrow decorate>{event}</Card.Eyebrow>

For stateful components, I either opt to leave them as React components (because mutations and re-rendering can get complicated!) or moving side effects to a separate Client-Side Scripts.

I do prefer next/head for composing <title> and <meta> tags within the page itself.

For example, a title like “23 Results Found” would come from search.astro.

Astro’s solution is to pass props to a Layout:

<Layout title={`${results.length}` Results Found`}>
  <h1>Search results<h1>

I originally tried to use Named Slots for this, but it didn’t work:

<!-- layout.astro -->
  <slot name="title" />
<!-- page.astro -->
<title slot="title">My title</title>

This would just render <title> where it existed in the page rather than in the layout.

Script Loading

When using Content Collections, Script loading doesn’t work the same as with .astro files. This is called out in the docs, but mentally it’s easy to forget what constraints your MDX writing.

Especially when writing content with live, component examples.

The solution is to move components & scripts into a .astro file, then import it at the top of the MDX file.

Vercel Framework

Because main was currently a Next.js project, all of my preview builds were failing because next couldn’t be found.

To fix this, I explicitly set the framework to astro in vercel.json:

  "framework": "astro"

I wish Vercel would auto-detect the framework from the package.json rather than having it explicitly set in the GUI’s “Project Settings”.

The documentation should add this to the Troubleshooting section rather than rely on Vercel’s inference, which only works for greenfield projects.

Periodic Crashes

When intermixing React JSX + Astro, periodically I would get errors like these:

  const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);

TypeError: $$result.createAstro is not a function
    at eval (/src/components/Button.astro:8:27)

The dev server would hard-crash with this error & I’d have to restart. It takes mostly trial & error to resolve it since the error message contains eval’d code, not actual source 😔

I’ve found that having Astro components render React that also try to re-use Astro components is often the problem. In these cases, converting all leaf nodes to Astro components and working up to the top-level React components resolves the errors.

Still, I’d rather there be a 500 screen from Astro and not have to restart the dev-server.


In isomorphic React applications, there’s a need to have dynamic, yet consistent, IDs between the server & client for hydration:

useId is a React Hook for generating unique IDs that can be passed to accessibility attributes. –

I was naively looking for the equivalent from Astro until I remembered that **Astro components are static & rendered by the server and not hydrated:

Can I Hydrate Astro Components?

Instead, I can generate a unique id simply with:

const id = `section-${Math.random().toString(16)}`


Once I get my apps working, I migrate to Preact to save bundle sizes. Because this was a migration, it makes sense to migrate later than in the beginning and potentially break existing functionality.

Integrating Preact automatically took care of most of the changes:

diff --git a/astro.config.mjs b/astro.config.mjs
index 0c8e9c8..7f8abac 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,6 +1,6 @@
 import image from '@astrojs/image'
 import mdx from '@astrojs/mdx'
-import react from '@astrojs/react'
+import preact from '@astrojs/preact'
 import sitemap from '@astrojs/sitemap'
 import tailwind from '@astrojs/tailwind'
 import vercel from '@astrojs/vercel/static'
@@ -14,12 +14,12 @@ export default defineConfig({
   integrations: [
-    react(),
       serviceEntryPoint: '@astrojs/image/sharp',
+    preact({ compat: true }),
   output: 'static',
   adapter: vercel(),
diff --git a/tsconfig.json b/tsconfig.json
index 46ec881..39e7273 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,7 @@
   "compilerOptions": {
     "baseUrl": ".",
     "jsx": "react-jsx",
-    "jsxImportSource": "react",
+    "jsxImportSource": "preact",
     "paths": {
       "@/*": ["src/*"]

But I found it went the smoothest by aliasing react & react-dom to @preact/compat rather than relying on pnpm.overrides (which didn’t work for me).

diff --git a/package.json b/package.json
index 25c4e48..93bdc6d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
   "dependencies": {
     "@astrojs/image": "^0.12.1",
     "@astrojs/mdx": "^0.14.0",
-    "@astrojs/react": "^1.2.2",
+    "@astrojs/preact": "^1.2.0",
     "@astrojs/sitemap": "^1.0.0",
     "@astrojs/tailwind": "^2.1.3",
     "@astrojs/vercel": "^2.4.0",
@@ -22,8 +22,9 @@
     "@types/react-dom": "^18.0.6",
     "astro": "^1.7.2",
     "clsx": "^1.2.1",
-    "react": "^18.0.0",
-    "react-dom": "^18.0.0",
+    "preact": "^10.6.5",
+    "react": "npm:@preact/compat",
+    "react-dom": "npm:@preact/compat",
     "sharp": "^0.31.3",
     "tailwindcss": "^3.0.24",
     "three": "^0.148.0"

Interestingly, Astro began to warn that client:only must pass the component’s correct framework. Oddly enough this, this worked before migrating to Preact?

diff --git a/src/pages/index.astro b/src/pages/index.astro
index 13b97ea..75a8a9b 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -22,7 +22,7 @@ const posts = (await getCollection('blog'))
       clipPath: 'polygon(0 1.5vw, 100% 0, 100% calc(100% - 2.5vw), 0 100%)',
-    <Canvas client:only />
+    <Canvas client:only="preact" />
   <Container className="mt-9">
     <div class="max-w-2xl">


Subsequent builds take ~5s.

Image & Picture

@astrojs/image provides 2 dynamic components:

In practice, <Picture> is most analogous to next/image for responsive images because it also supports the sizes attribute.

The only difficult is also providing widths in pixels when Tailwind’s units are ambiguous – w-72 might mean 18rem, or 288px ¯_(ツ)_/¯

For non-responsive images, <Image> is pretty much like <img>, except that width or height need to be provided alongside aspectRatio.

I typically set a maximum width of 1024 and a aspectRatio of 16:9.

But what makes these so handy is that I can use a import statement for the src!

  alt="My desk"

For SVGs, in the future I’ll look into astro-icon.

Social Images

Unfortunately, to use @vercel/og requires Edge Runtime (Limits)

I… didn’t see this until late at night after experimenting for several hours 😳

But, @astrojs/image does not support the Edge Runtime!

I was able to get social images running locally, but failing on Vercel, likely due to yoga-wasm-web/asm, satori/wasm, and sharp for rendering.

With a clear head today, I’m going to try out resvg-js for rendering & see if that makes the difference.

I did learn something new about importing ?raw files in Astro/Vite, though! Binary doesn’t cleanly get imported for use by satori.

So instead, I modified my astro.config.mjs with a custom hexLoader

/** @type {import('vite').Plugin} */
const hexLoader = {
  name: 'hex-loader',
  transform(code, id) {
    const [path, query] = id.split('?')
    if (query != 'raw-hex') return null

    const data = fs.readFileSync(path)
    const hex = data.toString('hex')

    return `export default '${hex}';`

export default defineConfig({
  vite: {
    plugins: [hexLoader],

Then, I could import the fonts via ?raw-hex:

import ttf from '../../../public/Inter/static/Inter-Regular.ttf?raw-hex'

And finally create a ArrayBuffer by:

const fromHexString = (hexString) =>
  Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)))

const inter = fromHexToString(ttf)


Even worse, just trying to use satori@^1.0.0 has tons of ESM/CJS issues, even when using ssr.noExternal:

require() of ES Module ./node_modules/.pnpm/yoga-wasm-web@0.3.0/node_modules/yoga-wasm-web/dist/asm.js from ./node_modules/.pnpm/satori@0.1.0/node_modules/satori/dist/index.cjs not supported.
  Instead change the require of asm.js in ./node_modules/.pnpm/satori@0.1.0/node_modules/satori/dist/index.cjs to a dynamic import() which is available in all CommonJS modules.

Based on some other examples I found, satori works with v0.0.45, so this is clearly a bug with Satori.

But the problem is, even when I get this working locally, is that Vercel has cryptic errors that I have no way of diagnosing:

Function Status:None

Edge Status:500

Duration:25.66 ms

Init Duration:N/A

Memory Used:267 MB


User Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/ Safari/537.36

2023-01-12T21:57:47.502Z	9f0bc511-56f8-4b17-ad95-a4581c3f4528	ERROR	Invoke Error 	{"errorType":"Error","errorMessage":"socket hang up","code":"ECONNRESET","stack":["Error: socket hang up","    at connResetException (node:internal/errors:711:14)","    at Socket.socketOnEnd (node:_http_client:518:23)","    at Socket.emit (node:events:525:35)","    at Socket.emit (node:domain:489:12)","    at endReadableNT (node:internal/streams/readable:1359:12)","    at process.processTicksAndRejections (node:internal/process/task_queues:82:21)"]}

On a hunch, I went to a preview build for /api/og.png, and it worked!

So it looks like serving normal content is failing on Vercel with output: server with this change 🤔

Resolved – It was a bug in Astro <Image> component that’s since been fixed. 🤦‍♂️

Preview Builds & import.meta.env

I found that I was always getting import.meta.env.DEV === true in my preview builds.

So instead, I used Astro.url.origin to be consistent across environments:

-const SITE = import.meta.env.PROD
-  ? import.meta.env.SITE
-  : 'http://localhost:3000'
+const { origin } = Astro.url

Sitemap & SSR

When running pnpm build with an SSR site, you’ll get this warning:

@astrojs/sitemap: Skipped!
No pages found! We can only detect sitemap routes for "static" builds. Since you are using an SSR adapter, we recommend manually listing your sitemap routes using the "customPages" integration option.

Example: `sitemap({ customPages: [''] })`

What sucks is that this config can’t use import { getCollection } from 'astro:content', utilities in src/lib/getPosts.ts, Astro.glob, or really anything you’re used to.

The only thing available is import.meta.glob('./src/content/**/*.mdx').

Using this, I basically reproduced what getCollection('blog') does:

const site = import.meta.env.PROD
  ? ''
  : 'http://localhost:3000'
const paths = import.meta.glob('./src/content/**/*.mdx')
const slugs = Object.keys(paths).map((file) =>
const customPages = => `${site}/${slug}`)


export default defineConfig({
  integrations: [
    sitemap({ customPages })

Unfortunately, the same issues with import.meta.env.PROD === false are true in preview branches for astro.config.mjs.

To fix this, I exposed System Environment Variables to the build and conditionally use process.env.PUBLIC_VERCEL_URL:

const site = process.env.PUBLIC_VERCEL_URL
  ? `https://${process.env.PUBLIC_VERCEL_URL}`
  : 'http://localhost:3000'

But now that customPages are working, my sitemap is missing all src/pages/*!

My final customPages is now computed via:

const content = Object.keys(import.meta.glob('./src/content/**/*.mdx')).map(
  (file) => file.split('./src/content/').pop().split('.mdx').shift()
const pages = Object.keys(import.meta.glob('./src/pages/**/*.astro')).map(
  (file) => file.split('./src/pages/').pop().split('.astro').shift()
const customPages = [...pages, ...content].map((slug) => `${site}/${slug}`)


With the majority of the work done to convert my blog, I’m left with the following impressions:

  1. Astro feels like the old days of the web. This is a good thing. HTML is HTML, <script> and <style> tags are copy/paste-able, and modern libraries like Svelte, Vue, & React are an extension away.

    It feels like I start with form, then get to choose how to progressively add function. Remix may say the same, as it centralizes on primitives like Request & Response. But being React-first rather than HTML-first means abstractions for link/style/script are essential.

  2. Astro’s growth and positive impressions are warranted, & will only improve: State of JS

  3. With this growth, bugs are shallower but getting fixed pretty fast.

  4. There are still rough edges with boilerplate around dynamic content, sitemaps, dynamic <head> tags, & image optimization. But, it’s clear that the team is optimizing for these common use-cases and rapidly experimenting.

Astro will likely become my default framework for new projects and multi-framework apps (such as documentation sites & component libraries) 🎉