Sanna Jammeh. Contact me
From Next.js to Astro in a weekend main image

From Next.js to Astro in a weekend

The why and how I migrated my Next.js blog and portfolio to Astro 4 in a weekend.

The why

Next.js has long served as my standard go-to meta-framework for building static web sites and applications. It’s served me well, but after trying out Astro for some client work, I was hooked. I already knew I wanted to change the look and feel of my portfolio. So I figured I’d start there, but I quickly realized that we’ve come a long way in terms of DX and Performance, and in my opinion, Astro sits at the top.

Next is a great framework, for fast computers…

This is the primary reason I love Astro so much. It’s fast. Picture the period we went from greyscale television to color, felt like the future right? Well, now we can barely watch a movie in 480p without feeling something is off. When Next 12 came out, this was exactly how I felt. I was using the same machine I used now (a 2021 MBP M1), it felt lightning fast, but after using Astro for a while, the same feeling ocurred. For me, Astro feels an order of magnitude faster than Next, and for sites I consistently change, that’s a big deal.

Thinking in Javascript

Astro is not bound by React the same way Next is. For the longest time I’ve been “thinking in React”, and while I still love React, I’ve been wanting to try something new. Astro allows me to do that, I am free to use any reactivity framework I want, or none at all. So as a challenge to myself, I decided to use vanilla JS Web components for the small interactions I needed.

The how

The migration was simple. With Astro’s DX, speed, content collections and integration support, nothing took longer than it needed to.

The stack

Here is a quick comparison of the stack I used for both sites:

Tailwind CSSUno CSS + Lightning CSS
ReactWeb Components
Handrolled MDX fetchingAstro
feed + handrolled (RSS generation)Astro

Notice a trend? 😉

Moving the blog

In terms of content, I had to do nothing. Astro supports MDX out of the box, so I just had to copy the content over and it worked. In order to achieve the same level of customizability in Next, I had to handroll a combination of next-mdx and mdx-bundler.

Previous Next.js setup

These are the steps (shortened heavily for this post) I had to take to get the blog up and running in Next:

1. Create a custom MDX fetcher in Next
export const getAllFrontmatter = async (fromPath) => {
  const PATH = path.join(DATA_PATH, fromPath);
  const paths = await glob(unixify(`${PATH}/**/*.mdx`));

  return Promise.all( (filePath) => {
      const file = path.join(filePath);
      const source = await fs.readFile(file, "utf8");
      const stat = await fs.stat(file);
      const { data, content } = matter(source);

      return {
        ...(data as Frontmatter),
        publishedAt: stringToDate(data.publishedAt).toISOString(),
        slug: path.basename(filePath).replace(".mdx", ""), // file name without extension
        wordCount: content.split(/\s+/g).length,
        readingTime: readingTime(content),
        modified: stat.mtimeMs,
        created: stat.birthtimeMs,
      } as Frontmatter;

export const getMdxBySlug = async (basePath, slug) => {
  const source = await fs.readFile(
    path.join(DATA_PATH, basePath, `${slug}.mdx`),

  const { frontmatter, code } = await bundleMDX({
    mdxOptions(options) {
      options.rehypePlugins = [
        ...(options.rehypePlugins ?? []),
        [withTocExport, { name: "toc" }],

      return options;

  return {
    frontmatter: {
      ...(frontmatter as Frontmatter),
      publishedAt: stringToDate(frontmatter.publishedAt).toISOString(),
      wordCount: code.split(/\s+/g).length,
      readingTime: readingTime(code),
    } as Frontmatter,
2. Load the data in Next’s getStaticProps & getStaticPaths
export const getStaticPaths: GetStaticPaths = async () => {
  // Get paths from markdown files in posts directory
  const posts = await getAllFrontmatter("");
  if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) {
    await mdxCache.set(posts);

  return {
    paths: => ({ params: { slug: p.slug } })),
    fallback: false,

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const { slug } = params;

  const { frontmatter, code } = await getMdxBySlug("", slug);
  const related = await findRelatedPosts(slug as string);

  const { mainImage } = frontmatter;

  return {
    props: {
      blurDataURL: mainImage ? await getBlurDataURL(mainImage) : null,
3. Render the thing
const exportData = useMemo(() => getMDXExport(code) as ExportWithTOC, [code]);
const Component = useMemo(() => exportData.default, [exportData.default]);

return (
    <Component />

Current Astro setup

Albeit simplified for this post, the setup packs a punch. Astro’s content collections control the entire rendering pipeline from fetching the articles to generating the table of contents and rss feeds.

1. Create a collection config and paste the files
// src/content/config.ts
const postsCollection = defineCollection({
  type: "content",
  schema: ({ image }) =>
      title: z.string(),
      description: z.string(),
      tags: z.array(z.string()).optional(),
      image: image().optional(),

export const collections = {
  posts: postsCollection,
2. Render the thing
// pages/blog/[slug].astro
export const getStaticPaths = async () => {
  const posts = await getCollection("posts");
  const paths = => ({
    params: {
      slug: post.slug,
    props: {
  return paths;

const { post } = Astro.props;

const { Content } = await post.render();
  <SEO slot="head" {...seoProps} />
    { && (
          widths={[300, 600, 900, 1200]}
          alt={`${} main image`}
          class="rounded-2xl shadow-xl mt-8 border-2 border-black"
    <Content />

Thats it!

Client side interactivity

I wanted to keep the site as simple as possible, so I decided to use vanilla JS for the client side interactivity. As a matter of fact, the only JS used in this site is for the floating navigation bar. I used a combination of Web Components and the URLPattern API to achieve this.

The markup
import { classed } from "@tw-classed/core";
const navContainer = classed("...unoClasses");
const navItem = classed("...unoClasses");

<nav id="nav-container" class={navContainer()}>
  <div class="flex gap-4 justify-center items-center">
    <a class={navItem()} data-matcher="/" href="/">Home</a>
    <a class={navItem()} data-matcher="/blog/:path*" href="/blog">Blog</a>
    <a class={navItem()} data-matcher="/projects/:path*" href="/projects"

<template id="nav-indicator-template">
    class="left-0 absolute top-50% -translate-y-50% bg-black rounded-xl -z-1"

  .nav-item[data-indicated="true"] {
    color: white;

  body {
    padding-bottom: 10rem;
    background-color: #f8f8f8;
The JS

The Javascript is pretty simple, but can be cleaned up a bit. I used the URLPattern API to match the current URL to the navigation links, and then initialized the nav-indicator web component.

Polyfilling the URLPattern is a breeze with Astro, as it supports dynamic imports and top level await out of the box

// @ts-ignore: Property 'URLPattern' does not exist
if (!globalThis.URLPattern) {
  await import("urlpattern-polyfill");

Then for the element itself. I’m using Vanilla Web Components, but Lit or any other framework would work just as well.

class NavIndicator extends HTMLElement {
  connectedCallback() {
    const content = document
    this.thumb = this.querySelector<HTMLSpanElement>("span")!;




  loadItems() {
    // Loads the navigation items and creates a URLPattern for each

  addListeners() {
    // Adds the listeners for the navigation items

  moveToActive() {
    const item = this.matcher.match({ pathname: window.location.pathname });

  move(item: HTMLElement) {
    // Moves the indicator to the current item

customElements.define("nav-indicator", NavIndicator);

The full source code for this component can be found here. To me, this felt like a breath of fresh air compared to the JSX I was used to writing.

The little things that matter

RSS feed generated in under 20 lines

A simple route in pages/rss.xml.ts is all it takes to generate an RSS feed. Astro takes care of the rest.

import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { getCollection } from "astro:content";

export async function GET(context: APIContext) {
  const blog = await getCollection("posts");
  return rss({
    title: "Sanna Jammeh’s Blog",
      "A blog about web development and other things I find interesting.",
    items: blog
      .map((post) => ({
        link: `/blog/${post.slug}`,
      .toSorted((a, b) => b.pubDate.getTime() - a.pubDate.getTime()),
    // (optional) inject custom xml
    customData: `<language>en-us</language>`,

Lightning CSS

As Astro and UNO both run in Vite, LightningCSS - a fast Rust CSS parser - can be plugged in with a simple config change. This allows for a much faster dev experience when working with large amounts of CSS.

// astro.config.mjs
  vite: {
    css: {
      transformer: "lightningcss",

Not just children

Astro’s slot API allows for “fine grained child placement”. This means that you can place children in specific slots and decide where they end up in the parent component. This is how I use it to place my SEO tags in the <head> of the document.


    <slot name="head" />
    <slot />


import {SEO} from "astro-seo"

  <SEO slot="head" {...seoProps} />
    <Content />

Now the SEO tags are placed in the <head> of the document, and the rest of the content is placed in the <body>.


In terms of hours, the migration took a little less than a weekend to complete. I’m very happy with the results and especially the DX gains achieved by switching to Astro.


Lighthouse score

Lower is better

Vercel Build time (avg)1m 2s31s
First Load JS112kb5.32kb + 4.9kb
Insights FCP2.2s1s
Insights TBT7s30ms
Speed index3s1.3s

That is a 50% reduction in build time, 95% reduction in JS size, 54% reduction in FCP and 99.5% reduction in TBT. I’m very happy with these results, and I have barely started optimising for performance. When it comes to the build time, Next.js uses on demand image generation, whereas Astro is currently using build time image generation. I expect the build time to go down even further if I switch to Vercel’s image service.




Although Astro is excellent for static sites, it’s not a complete replacement for Next.js. Next.js is still the go-to framework for building dynamic server powered sites and applications.

In general, I’m very happy with the results of the migration. Astro has allowed me to simplify my stack, and focus on the things that matter. I’m looking forward to seeing how Astro evolves, and I’m excited to see what the future holds for this site. Expect many new sprinkles of interactivity in the future.

The new Astro 4 source is available here: