I first wrote about moving my blog to Jekyll back in 2017, it was from Wordpress. Eight years down, I’ve moved again — this time to Astro. It’s the first time I’ve genuinely enjoyed the act of working on the engine of my blog rather than kinda-tolerating it.

I won’t say it was completely smooth, but most things made sense since it generated standard static site. By the end of it I’d written a remark plugin for reading time, a rehype plugin to handle external links, content collections (blog, snippets, labs), and a tiny utility library for view transitions; all to match my existing look-and-feel and workflow. None of that is Astro-specific in spirit, but Astro is the first stack I’ve used where adding all of it felt natural rather than fighting the framework.

Why move at all?

Jekyll served me well. It’s still serving plenty of people well. My reasons for moving were boring and accumulated:

  • Ruby toolchain pain on macOS. TBH, coming back to ruby after years, fixing broken gems, all was an different adventure, just to get the system running.
  • Liquid limits. Doing anything component-shaped meant either a partial-with-includes or jumping into Jekyll plugins. Both felt heavier than they should.
  • No type safety on the data layer. I had typos in frontmatter that I only caught when a page rendered with literal {{ post.title }} text in production.
  • Build times. Not catastrophic, but jumping back into the editor after a tweak was a 4-5 second wait. With Astro’s HMR it’s instant.
  • Saying goodbye to SCSS I really loved SCSS, it was a really useful, but after node-sass reached EOL, there was no stable way to make it work.

There were other options too which I had already worked on - Gatsby, Hugo, Docusaurus; but somehow Astro’s simplicity and island based approach felt more what’s right for me.

Astro fits the way I think about a content site - statically generated, server-rendered HTML by default, islands of interactivity where I want them, real TypeScript everywhere.

The migration itself

The outline of migration was - migrating stylesheets, migrating assets, replacing liquid templating, frontmatter update, adding plugins for missing pieces.

Stylesheets

Jekyll has first-class SCSS support. I’d been writing .scss files for years, leaning on nesting, @mixin, and @include for most of the reusable patterns, using Compass for additional functionality. Now years later, when node-sass has already hit EOL and the sass gem started requiring workarounds to install cleanly, I decided not to carry SCSS forward into the new stack.

The move was mostly mechanical - flatten the nesting, replace mixins with CSS custom properties, and let the cascade do the rest. Where SCSS had something like:

$primary-color: #e96002;

.post-title {
  color: $primary-color;

  &:hover {
    color: darken($primary-color, 10%);
  }
}

The morden CSS way to use a custom variables with var():

:root {
  --primary-color: #e96002;
}

.post-title {
  color: var(--primary-color);
}

.post-title:hover {
  color: color-mix(in srgb, var(--primary-color) 80%, black);
}

color-mix() replaced most of my darken/lighten calls. It’s less concise than SCSS, but it ships with the browser - no toolchain required. Scoped styles in .astro files (<style> blocks compile to scoped class selectors) covered the component-level stuff.

One thing I didn’t expect (which also took quite a lot of time): dropping SCSS forced me to audit how much of it was actually necessary. The answer was “less than I thought”. Most of the useful bits were the variables and nesting. CSS does both of those now.

Replacing Liquid templating

Liquid was actually one of the parts I didn’t mind leaving. I’d been comfortable with it, the {{ }} / {% %} syntax, piping output through filters, includes. It’s the same mental model as Laravel’s Blade, which I’d used to use on the backend side, so it never felt alien. But Astro’s templating is genuinely better, and the transition was smoother than I expected because of that familiarity.

Jekyll runs every file through Liquid before rendering, so layouts, partials, and posts can all contain {{ }} expressions and {% %} tags.

My layouts would look something like this:

{% for post in site.posts limit:5 %}
<a href="{{ post.url }}">{{ post.title }}</a>
{% endfor %}

Astro layouts are .astro files with a frontmatter script block and JSX-style templating. The equivalent:

---
const posts = await getCollection("blog");
---

{posts.slice(0, 5).map(post => (
  <a href={`/blog/${post.id}`}>{post.data.title}</a>
))}

The mechanical translation was fine. The bigger shift was mental - in Liquid, the data comes from Jekyll’s site variable which is globally available everywhere. In Astro, you explicitly fetch what you need in each page’s frontmatter. It’s more verbose, but I stopped having to guess where a variable came from.

The {% include %} partials became .astro components. Most of them barely changed structurally - the slots model is close enough to Jekyll’s include syntax that porting was straightforward.

One gotcha: Liquid’s | date_to_xmlschema and similar filters have no direct equivalent. I ended up writing small helpers in src/utils/formatter.ts for date formatting and the view-transition slug logic. Less magic, more readable.

Frontmatter shape

Jekyll posts looked like -

---
layout: post
title: "Some Title"
date: 2017-03-07
categories: [blog, quick-fix]
tags: [Laravel, web development, MySQL]
comments: true
---

Astro’s content collections are typed by zod schemas. My blog schema in src/content.config.ts looks like -

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string().optional(),
      pubDate: z.coerce.date(),
      updatedDate: z.coerce.date().optional(),
      heroImage: image().optional(),
      categories: z.string().array(),
    }),
});

A few things to call out -

  • glob loader handles both .md and .mdx files in one go.
  • z.coerce.date() lets Jekyll-style 2017-03-07 strings parse without any pre-processing.
  • image() is Astro’s helper that turns relative paths into optimised images at build time.
  • categories: z.string().array() keeps me honest - typo a category name and the build fails loudly.

I kept the layout: post, tags, and comments fields in the frontmatter even though they’re not in the schema, partly out of nostalgia and partly to make the migration script idempotent. Astro ignores extra fields by default, which is the right call.

The article migration itself was a manual move from the old _posts/ directory into the new src/content/blog/YYYY/MM/ shape. Even though year-month directory is not a pattern in astro, I kept it as it keeps things organised. The body content was already plain markdown - no Liquid in my posts.

Asset paths

Jekyll uses {{ '/assets/img/foo.png' | prepend: site.baseurl }}. Astro uses plain absolute paths for files in public/, or relative paths processed through astro:assets for build-time optimisation.

I had to choose. For my old posts I kept everything in public/assets/img/ and rewrote the Liquid to plain /assets/img/foo.png. For new posts, the per-post heroImage field uses astro:assets:

---
heroImage: ./202504_rohan_shewale_arch_system_fix.jpg
---

That image is co-located with the post markdown and gets resized, format-converted, and lazy-loaded automatically. It’s the small kind of feature that adds up.

Content collections - blog, snippets, labs

Everything on the site lives in one of three collections -

export const collections = { blog, snippets, labs };

Each has a slightly different schema. blog requires categories because the index pages group by them. snippets has tags instead, because they’re short and the categorisation is finer-grained. labs is even simpler - title, description, date, optional tags. The whole config is about 40 lines.

The win of having multiple collections rather than one big “posts” with a type field is that each gets its own typed getCollection() call:

const labs = await getCollection("labs");
// labs is typed as CollectionEntry<"labs">[] - no narrowing required

That typing flows through to layouts:

type Props = CollectionEntry<"labs">["data"] & {
  readMinutes?: string;
};

If I add a field to the labs schema, the layout breaks at compile time until I handle it. That single property - typed data flowing into typed components - is the thing I missed most on Jekyll.

Layouts per collection

Keeping things consistent as before, I have separate layout files for each collection:

  • src/layouts/BlogPost.astro
  • src/layouts/SnippetPost.astro
  • src/layouts/LabPost.astro

There’s about 60% overlap between them, and at one point I tried to consolidate into a single “PostLayout” with a type prop. It got ugly fast - different metadata, different typography, different next/prev navigation, different share buttons (snippets don’t get social share, labs do). The duplication turned out to be cheaper than the abstraction.

It’s the simplest version of “duplicate it twice before extracting” - and it worked. Each layout is a single file that does one thing well.

Plugins I wrote

This is the part that surprised me - Astro made writing my own remark and rehype plugins genuinely easy. The pipeline is just a list of functions that operate on the AST.

Reading time, as a remark plugin

Every blog post has a “X min read” indicator:

import getReadingTime from "reading-time";
import { toString } from "mdast-util-to-string";

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree);
    const readingTime = getReadingTime(textOnPage);
    data.astro.frontmatter.minutesRead = readingTime.text;
  };
}

That’s the entire plugin. Walk the markdown AST, flatten it to text, run the reading-time package, attach the result to the frontmatter so layouts can read it.

And wireup in astro.config.mjs:

markdown: {
  remarkPlugins: [remarkReadingTime],
}

In a layout:

const { Content, remarkPluginFrontmatter } = await render(post);
// remarkPluginFrontmatter.minutesRead === "3 min read"

Thanks to well written astro docs I actually wrote it in quite less time. The fact that the data flows from a build-time AST plugin all the way into a typed component without any glue is the simplicity of this framework.

I wanted any link to a domain other than mine to open in a new tab. Jekyll’s templates would accept the target="_blank" attribute which could be piped, though not in markdown standard, but to bring back the functionality in markdown itself, a simple rehype plugin:

import { visit } from "unist-util-visit";

export const targetBlank = ({ domain = "" } = {}) => {
  return (tree) => {
    visit(tree, "element", (e) => {
      if (
        e.tagName === "a" &&
        e.properties?.href &&
        e.properties.href.toString().startsWith("http") &&
        !e.properties.href.toString().includes(domain)
      ) {
        e.properties["target"] = "_blank";
      }
    });
  };
};

The !href.includes(domain) check is the bit which escapes my own site’s links to not pop open a new tab. Wired up the same way:

markdown: {
  rehypePlugins: [[targetBlank, { domain: "rohanshewale.com" }]],
}

Plugin authoring isn’t a feature I expected to use this often. With Astro it’s part of the toolbox, not a last resort. (Original inspiration for the pattern came from Davide Salvagni’s post - saved me reading the unist docs from scratch.)

View transitions, with one helper

Previously, I had added view-transistion support for blog, and it worked flawlessly. Migrating it to Astro was quite simple, a tiny utility function generated valid names for titles.

export function formatViewTransistionName(src: string, prefix: string = "") {
  if (!src) return "";

  const transformName = src
    .toLowerCase()
    .replace(/['']|:/g, "")
    .replaceAll(" ", "-");

  const namePrefix = prefix ? `${prefix}-` : "";
  return `view-transition-name:${namePrefix}${transformName}`;
}

In a list page:

<a style={formatViewTransistionName(post.title, "blog")}
   href={`/blog/${post.id}`}>
  {post.title}
</a>

In the post page:

<h1 style={formatViewTransistionName(title, "blog")}>
  {title}
</h1>

The prefix (blog, snippet, lab) prevents collisions across collections. The slug-style transform makes the names CSS-safe. Click a post in a list, the title morphs into the new page’s heading. It’s a small bit of polish that took maybe an hour to get right.

For the gotchas worth knowing about, Jim Nielsen’s view-transition-name gotchas and Dave Rupert’s getting-started post are the two references I came back to. The MDN reference at developer.mozilla.org/en-US/docs/Web/CSS/@view-transition covers the spec side.

Closing

Astro is the first stack where I felt the framework getting out of the way rather than imposing its own opinions. Content collections, plugins, image optimisation - all of them are small, composable pieces that look like ordinary code rather than framework magic.

Years on Jekyll, and the move took me one weekend. The fact that I’ve kept tinkering since, I have a few more projects which I would love to use this simplicity. A platform you want to keep building on is the only one worth being on.