Blog

Bye Bye Jekyll, Hello Astro

I’ve been running tecnobrat.com for a long time, and the site has worn a lot of hats: Octopress, Jekyll, a theme, custom plugins, and enough “just make it work” glue to qualify as archaeology.

It worked — until it didn’t.

This post is the write-up of migrating the site from the old Jekyll site to the current Astro version you’re reading now.

What I was running before

The old site was a Jekyll build with:

  • A date-based permalink structure (/:year/:month/:day/:title.html)
  • The Minimal Mistakes theme
  • Taxonomy pages (categories/tags)
  • A comment system configured via Disqus
  • A custom photography pipeline powered by a Ruby plugin and ImageMagick

The photography piece is worth calling out because it’s a great example of why I moved on: the Jekyll repo includes a bespoke gallery generator (_plugins/jekyll-art-gallery-generator.rb) that:

  • Walks a _photography/ folder
  • Reads config from _data/gallery.yml
  • Generates thumbnails and “best image” variants
  • Strips EXIF (and optionally watermarks/resize-limits) using rmagick

That’s a lot of power… and a lot of native dependency surface area.

The Gemfile also tells a story: it pins Jekyll ~> 4.4.0, pulls in rmagick, and even pins google-protobuf because newer builds wouldn’t compile cleanly on Fedora at the time. That’s exactly the kind of “works on my machine, please don’t touch it” friction I was ready to retire.

My constraints for the migration

I didn’t want a “new app”. I wanted the same site, with a better build.

  • Static-only output (no server runtime)
  • S3 + CloudFront hosting stays the same
  • Keep URLs working (old blog permalinks should redirect)
  • Keep content simple (Markdown-first, with the option of MDX when it’s useful)
  • Prefer strict typing for site data and metadata

The big shift: themes → typed content + components

Jekyll is a wonderful tool, but it encourages an ecosystem of theme conventions, Liquid templates, and plugin hooks.

With Astro, the “unit of composition” becomes components + data, with static rendering by default. Instead of discovering behavior via theme docs, I wanted a repo where the behavior is visible in TypeScript and .astro files.

In this codebase:

  • Content lives in collections under src/content/
  • Layouts/components are explicit (src/layouts/BaseLayout.astro, src/components/*)
  • Metadata is typed with Zod in src/content/config.ts

That last point is a quiet win: in the Astro site, blog and page entries share a common shape (title, optional description, optional header), and blog posts add date + tags. That makes it hard to accidentally ship malformed frontmatter.

Blog: keep Markdown, modernize the scaffolding

The blog is still Markdown/MDX — but the scaffolding is now “Astro-native”:

  • src/pages/blog/[slug].astro renders a post from the blog content collection
  • Dates are formatted via src/utils/dateFormat.ts
  • Tags are displayed inline on the post page
  • Blog listing and pagination are handled by the Astro pages under src/pages/blog/

I also added a nice little quality-of-life improvement: excerpts.

There’s a remark plugin (src/utils/remark-excerpt.ts) that generates a ~200 character excerpt from paragraph text (skipping code blocks and MDX imports/JSX). In the UI, excerpt is used for blog previews (and social/SEO description text); if it isn’t provided, a reasonable default is auto-generated (src/utils/excerpts.ts).

In Jekyll, I used an explicit excerpt_separator (<!--more-->). In Astro, I like having both options:

  • Write an explicit excerpt when I care about it
  • Otherwise let the build generate a reasonable default

Separately, description is reserved for the hero/header copy on the post itself.

Photography: Ruby pipeline → Astro assets

This was the most satisfying refactor.

In the Jekyll version, the gallery generator did a ton of image work at build time, and the source of truth was _data/gallery.yml plus filesystem discovery.

In the Astro version, the approach is:

  • Put photos in src/assets/photos/<gallery>/<file>.jpg
  • Define the “gallery index” metadata in the Photography page content
  • Use import.meta.glob() to load the photos for a gallery page
  • Use astro:assets to optimize images for display, and generate full-size versions for the lightbox

The key bit is in src/pages/photography/[slug].astro: it globs /src/assets/photos/*/*.{png,jpg,jpeg,webp} and filters by gallery slug.

Instead of pre-generating thumbnails on disk, Astro’s pipeline handles the resized images and caching as part of the build.

Net effect:

  • No Ruby native deps
  • No custom plugin execution
  • Much simpler mental model (files + a small amount of typed metadata)

CI/CD: PR previews and static deploys

The new repo also formalizes how the site is shipped.

There are three workflows:

  • PR preview deploy (.github/workflows/ci.yml):

    • Runs npm ci
    • Runs npm run check (type-check + lint + formatting check)
    • Builds the site with a PR-scoped base path
    • Syncs dist/ to a PR-scoped prefix in the same S3 bucket
    • Invalidates CloudFront for just that preview prefix
  • Production deploy (.github/workflows/deploy.yml):

    • On push to main, builds and deploys dist/ to the bucket root
    • Excludes the preview namespace so it doesn’t get wiped
    • Invalidates CloudFront for /*
  • PR preview cleanup (.github/workflows/cleanup.yml):

    • When a PR closes, removes the preview prefix from the bucket
    • Invalidates CloudFront for that preview prefix
    • Removes the GitHub deployment environment

The important part is what it enables: reviewing a static site change as a real deployed site before it hits production, without standing up any extra infrastructure.

One migration footgun is URLs.

The old site had dated permalinks like:

  • /2012/03/15/octopress-up-and-running.html

The new site uses blog slugs like:

  • /blog/2012-03-15-octopress-up-and-running/

To preserve inbound links (and my own muscle memory), I added explicit S3 website routing rules in Terraform (terraform/tecnobrat.com.tf) to redirect the known old post paths to the new ones with 301s.

I also included a couple of “broad strokes” redirects:

  • /categories/…/blog/ (302)
  • /photography/main//photography/ (302)

Not everything needs to be permanent — but the old blog post URLs absolutely do.

What I like better now

This migration wasn’t about chasing shiny things. It was about removing friction.

  • Build is Node-based (no Ruby toolchain, no native gems)
  • Site metadata is typed
  • Layouts and components are local and explicit
  • Images are handled by the framework
  • Deploys are automated and reviewable

What’s next

I intentionally kept the migration lean: get the new generator in place, keep the look/feel, keep content working, preserve URLs.

Future improvements I’m considering:

  • A dedicated tags page (and/or tag archives)
  • Search (client-side) if it ever feels necessary
  • Comments again — but only if they’re low-noise and low-maintenance

For now: the site is static, fast, and a lot easier to live with.

Leave a comment

Your comment will be pending admin review. If you're not logged in, we'll email you to confirm first.

Comments

Loading…