How I Rebuilt My Blog with Astro


Back in 2021 I wrote about building this blog in Next.js. The stack was Next.js, TypeScript, styled-components, and next-mdx-remote for the MDX rendering. It worked.

Five years later, “worked” stopped being enough. The site is a few pages of static text, but I was shipping it like a React app: runtime, hydration, and all the SPA plumbing that goes with it. None of that was doing anything for the reader, and every time I sat down to add something I felt the weight of it.

So I rewrote it in Astro. Here’s what changed, what I dropped, and why I’m not going back.

Why Astro

The Next.js setup wasn’t broken. It was just heavier than it needed to be. Every page navigated like a SPA. The styled-components runtime was injecting CSS on the client. next-mdx-remote was parsing posts that never change. None of that earns its keep on a blog.

Astro ships zero JavaScript by default. Every page is static HTML. If you need client-side JavaScript somewhere, you opt into it component by component with islands. For a blog where the only interactive element is a copy button on code blocks, that’s the right model.

The other big draw was content collections. The old setup required me to write a getStaticProps function for every page that needed post data: reading from the filesystem, parsing frontmatter, filtering unpublished posts by hand. Astro’s content collections make that a typed, zero-boilerplate API. You define the schema once and the rest of the site inherits it.

Getting Started

pnpm create astro@latest

Astro’s CLI will walk you through a few options. I picked the blog template to get a sensible starting point, then stripped it back to what I actually wanted.

The project structure looks like this:

src/
  components/
    BaseHead.astro
    BlogPostImage.astro
    Footer.astro
    FormattedDate.astro
    Header.astro
    HeaderLink.astro
  content/
    blog/
      my-first-post.mdx
  layouts/
    BlogPost.astro
  pages/
    index.astro
    blog/
      [...page].astro
      [...slug].astro
    uses.astro
    projects.astro
    contact.astro
    rss.xml.js
  content.config.ts
  consts.ts

If you’re coming from Next.js, the pages/ directory will feel familiar. The differences: .astro files replace .tsx, there’s no _app.tsx or _document.tsx, and layouts are just regular .astro components you import, not a framework concept.

Content Collections

This was the biggest quality-of-life win in the rewrite. In the Next.js version I had a utility function that read *.mdx files from disk with fs, parsed frontmatter with gray-matter, filtered out drafts, and sorted by date. Every page that listed or displayed posts called that function.

Now I define the schema once in src/content.config.ts:

src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
 
const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      published: z.boolean().optional(),
      publishedDate: z.coerce.date(),
      updatedDate: z.coerce.date().optional(),
      heroImage: image().optional(),
    }),
});
 
export const collections = { blog };

And query it anywhere with getCollection:

import { getCollection } from "astro:content";
 
const posts = (await getCollection("blog"))
  .filter((post) => post.data.published)
  .sort(
    (a, b) => b.data.publishedDate.valueOf() - a.data.publishedDate.valueOf(),
  );

The published filter, the date sort, the type safety on every field. It’s all handled. Add a field to the schema and forget to set it on a post, you get a build error instead of a runtime undefined. That alone has caught me a few times.

Layouts

In Next.js, my Layout was a React component that wrapped every page with the ThemeProvider, GlobalStyle, and any shared markup. I passed a title prop and children.

In Astro, a layout is just an .astro file with a <slot /> where the page content goes. No provider, no context, no component tree to wire up. Here’s an abbreviated version of BlogPost.astro:

src/layouts/BlogPost.astro
---
import type { CollectionEntry } from "astro:content";
import BaseHead from "../components/BaseHead.astro";
import Footer from "../components/Footer.astro";
import FormattedDate from "../components/FormattedDate.astro";
import Header from "../components/Header.astro";
 
type Props = CollectionEntry<"blog">["data"];
 
const { title, description, publishedDate, updatedDate, heroImage } =
  Astro.props;
---
 
<html lang="en">
  <head>
    <BaseHead title={title} description={description} image={heroImage} />
  </head>
  <body>
    <Header />
    <main id="main">
      <article>
        <div class="title">
          <FormattedDate date={publishedDate} />
          <h1>{title}</h1>
        </div>
        <slot />
      </article>
    </main>
    <Footer />
  </body>
</html>

The frontmatter block (between the --- fences) runs on the server at build time. Everything below is the template. That’s it. No hooks, no hydration concerns, no styled-components SSR dance that required a custom _document.tsx.

The MDX files themselves don’t reference the layout at all. That’s handled by the dynamic route at src/pages/blog/[...slug].astro, which loads the post and wraps it in BlogPost:

src/pages/blog/[...slug].astro
---
import { type CollectionEntry, getCollection, render } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
 
export async function getStaticPaths() {
  const posts = (await getCollection("blog")).filter(
    (item) => item.data.published === true,
  );
  return posts.map((post) => ({
    params: { slug: post.id },
    props: post,
  }));
}
 
type Props = CollectionEntry<"blog">;
 
const post = Astro.props;
const { Content } = await render(post);
---
 
<BlogPost {...post.data}>
  <Content />
</BlogPost>

getStaticPaths returns one path per published post and render(post) gives back a Content component that emits the MDX. The whole post-rendering pipeline lives in this one file.

Dropping Styled-Components

The Next.js blog used styled-components for everything. I had a ThemeProvider, light and dark theme objects, a GlobalStyle, and a custom useDarkMode hook to handle the prefers-color-scheme media query and localStorage persistence.

It worked, but the cost was real. Styled-components generates and injects styles at runtime, which adds to the bundle and produces a flash of unstyled content on first render. I papered over that with the componentMounted pattern in useDarkMode. The layout would render an empty <div /> until the hook had read localStorage and figured out the theme. It was fine. It was also a workaround for a problem I didn’t need to have.

The new site uses plain CSS with custom properties. Dark mode is handled entirely in CSS:

:root {
  --color-bg: #ffffff;
  --color-text: #1a202c;
  --color-accent: #f9b434;
}
 
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #1a202c;
    --color-text: #e2e8f0;
    --color-accent: #f9b434;
  }
}

No JavaScript. No flash. No runtime overhead. The browser handles it based on the user’s system preference, which is what I wanted in the first place.

Syntax Highlighting

The old Next.js blog used whatever MDX bundled by default, which was fine. The new setup uses rehype-pretty-code backed by Shiki. Shiki highlights at build time, so there’s no client-side highlighting library in the bundle at all.

Configuration lives in astro.config.mjs:

astro.config.mjs
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import { defineConfig, passthroughImageService } from "astro/config";
import rehypePrettyCode from "rehype-pretty-code";
import { transformerCopyButton } from "@rehype-pretty/transformers";
 
export default defineConfig({
  site: "https://kevinlangleyjr.dev",
  integrations: [mdx(), sitemap()],
  image: {
    service: passthroughImageService(),
  },
  markdown: {
    syntaxHighlight: false,
    rehypePlugins: [
      [
        rehypePrettyCode,
        {
          theme: "github-dark",
          keepBackground: true,
          transformers: [
            transformerCopyButton({
              visibility: "hover",
              feedbackDuration: 2_500,
            }),
          ],
        },
      ],
    ],
  },
});

The syntaxHighlight: false line tells Astro to skip its built-in highlighter so rehype-pretty-code can take over. The transformerCopyButton adds a copy button to every code block on hover, one of those small things that adds up when you’re reading a tutorial.

RSS Feed and Sitemap

In the Next.js version I wrote my own RSS feed and sitemap generator. It ran as a build step and wrote XML files to public/. It worked, but it was around 80 lines of plumbing I had to keep maintained.

Astro handles both with first-party integrations. The sitemap is fully automatic. @astrojs/sitemap reads your pages at build time and writes sitemap-index.xml with no config beyond adding it to the integrations array.

The RSS feed is a single file at src/pages/rss.xml.js:

src/pages/rss.xml.js
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import { SITE_DESCRIPTION, SITE_TITLE } from "../consts";
 
export async function GET(context) {
  const posts = (await getCollection("blog"))
    .filter((post) => post.data.published)
    .sort(
      (a, b) => b.data.publishedDate.valueOf() - a.data.publishedDate.valueOf(),
    );
 
  return rss({
    title: SITE_TITLE,
    description: SITE_DESCRIPTION,
    site: context.site,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.publishedDate,
      description: post.data.description,
      link: `/blog/${post.id}/`,
    })),
  });
}

That’s it. @astrojs/rss generates valid RSS 2.0. The content collections query is the same one I use everywhere else, so the RSS feed and the blog index page are pulling from identical code.

Build and Deploy

The old Next.js blog deployed to Vercel. This one builds to static HTML with astro build and deploys the same way. The build is fast (a couple of seconds for the whole site) and the output is a dist/ directory of plain HTML, CSS, and a tiny bit of JavaScript for the copy button transformer.

pnpm build

Was it worth it?

Yes. For a content site, Astro is just a better shape. The Next.js setup was clever, but a blog is mostly static content rendered through a consistent layout, and that’s exactly what Astro is built for. Layouts, content collections, and the integrations ecosystem cover every common need, and you don’t have to wire any of it up.

Things I actually miss from the Next.js setup: nothing, honestly.

Things I was surprised went away: the flash of unstyled content on dark mode, the getStaticProps boilerplate, the custom RSS and sitemap scripts, the _document.tsx SSR workaround for styled-components.

If you’re on a similar setup (a Next.js blog that started small and slowly accumulated infrastructure), it’s worth a look. The migration took me a weekend.