Switching from Jekyll to Hugo
May 2, 2025

I first heard about Hugo several years ago from a colleague who was building their website with it. At the time, I mentally filed it away as something to look into if I ever became dissatisfied with Jekyll. For the most part, Jekyll served me well—it’s flexible, well-documented, and familiar. But over time, a few friction points started to accumulate.

Why I Considered Leaving Jekyll

  1. Slow build times

    Even with modest content, my site took several seconds to compile. Switching from kramdown to Commonmark helped (as I described here), but it still wasn’t quick.

  2. Plugin dependency bloat

    I relied on extra plugins for essentials like RSS feeds, sitemaps, and SEO tags. That meant more config, more updates, and occasionally dealing with plugin quirks.

  3. Gem version maintenance

    Each site had its own Gemfile and Gemfile.lock, which introduced a regular maintenance burden to keep dependencies fresh and avoid version drift.

  4. Unclear future of Jekyll

    Development seemed questionable as I heard rumors of core maintainers stepping away, and while GitHub Pages still uses Jekyll, it only supports v3. Using Jekyll 4+ requires custom deploy setups anyway. While I’m not panicked about Jekyll’s development status, Hugo appears to have stronger momentum and active development.

Starting with a Small Hugo Migration

Rather than convert my largest project upfront, I chose to start with a smaller site to test Hugo. The goal: make the backend switch from Jekyll to Hugo without changing the site’s appearance.

Familiar Language, New Syntax

Hugo is written in Go, and I’ve been working with Go and Helm since 2022—so I found Hugo’s Go template syntax approachable. Here’s a side-by-side comparison of common Liquid (Jekyll) vs Go Template (Hugo):

Feature Jekyll (Liquid) Hugo (Go Template)
Page title {{ page.title }} {{ .Title }}
Site title {{ site.title }} {{ .Site.Title }}
Page date {{ page.date }} {{ .Date }}
Content {{ content }} {{ .Content }}
Loop {% for post in site.posts %} {{ range .Site.RegularPages }}
Conditional {% if page.draft %} {{ if .Draft }}

Porting the Layouts

Jekyll’s _layouts/*.html became layouts/_default/*.html in Hugo. A file like _layouts/default.html translated directly to layouts/_default/all.html (Hugo’s base layout). Other layout files went into their own directories inside layouts/.

Converting _includes/* to Shortcodes and Partials

Hugo splits logic into:

  • Shortcodes, used in content:

    Example: {{< photo >}} maps to layouts/shortcodes/photo.html file

  • Partials, used in templates/layouts:

    Example: {{ partial "navigation" . }} maps to layouts/partials/navigation.html file

Fun fact: shortcodes can call partials, but not the other way around.

Here’s an example of turning an _include/ file into a shortcode:

_include file in Jekyll

<div class="card bg-info mb-3 mx-auto" style="max-width: 50rem;">
  <div class="card-header">Overall Rating</div>
  <div class="card-body">
    <h5 class="card-title">{{ include.rating }}</h5>
    <p class="card-text">{{ include.description | smart_quotify }}</p>
  </div>
</div>

Shortcode in Hugo

<div class="card bg-info mb-3 mx-auto" style="max-width: 100%;">
  <div class="card-header">Overall Rating</div>
  <div class="card-body">
    <h5 class="card-title">{{ .Get "rating" }}</h5>
    <p class="card-text">{{ .Get "description" | partial "smartquotes" | safeHTML }}</p>
  </div>
</div>

Moving Static Assets

Jekyll stored everything in assets/; Hugo prefers static/. Here’s how things mapped:

Asset in Jekyll Asset in Hugo
assets/css/*.css static/css/*.css
assets/js/*.js static/js/*.js
assets/images/* static/images/*

Because I didn’t want to use the Hugo extended version to process SCSS, I flattened my SCSS files into a single static/css/site.css. That also simplifies how my HTML calls the CSS files:

<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/google_fonts.min.css">
<link rel="stylesheet" href="/css/lightbox.min.css">
<link rel="stylesheet" href="/css/photoswipe.min.css">
<link rel="stylesheet" href="/css/site.css">

Hugo doesn’t need the static written as part of the href; Hugo will place all static directories directly at the root of the final HTML files. This is different from with Jekyll, where static files in the assets/ directory would stay in the assets/ directory in the final HTML.

Updating Content Files

In Jekyll, a page’s front matter might look like this:

---
layout: page
navbar: Other
permalink: /page-one/
title: Page One
---

In Hugo, that becomes:

---
navbar: Other
title: Page One
---

Why?

  • No layout front matter key needed—Hugo uses default layout resolution based on content section and type, falling back to layouts/_default/single.html unless a more specific template exists. This can be controlled via a type front matter key.
  • No permalink needed in many cases—Hugo builds URLs based on content file paths (e.g./page-one/ from content/page-one/_index.md). This can be overriden with a url key if needed.

Rewriting Templating Syntax

I had to manually rewrite every {% ... %} block from Jekyll to the equivalent Hugo code. This took a while, especially for content-heavy projects! I’m glad I made the shift now instead of after I’d written 200+ blog posts!

Here’s an example of my photo _include that turned into a shortcode:

Calling _include in Jekyll

{% include photo.html
  image_url="/assets/images/test.png"
  caption="Test image"
  thumb_width="201"
  full_height="435" full_width="350"
%}

Calling shortcode in Hugo

{{< photo
  image="/images/test.png"
  caption="Test image"
  thumb_width="201"
  full_height="435" full_width="350"
>}}

Configuration Changes: _config.ymlhugo.yaml

Here’s a comparison of config files:

Jekyll _config.yml

author: Me
timezone: UTC
title: My Site

exclude:
  - Gemfile
  - Gemfile.lock
  - README.md
  - vendor

plugins:
  - jekyll-tidy

sass:
  sass_dir: assets/css/
  style: compressed

Hugo hugo.yaml

baseURL: https://mysite.com
enableGitInfo: true
languageCode: en-us
title: My Site

markup:
  defaultMarkdownHandler: goldmark
  goldmark:
    parser:
      attribute: true
      autoHeadingID: true
      extensions:
        typographer: true
    renderer:
      unsafe: true

taxonomies: {}

If I needed to include author info, or any other items I want to store in the config file, I can add parameters:

params:
  author: Me
  description: My cool website

Then, I can reference all of these config properties from my HTML:

Author => {{ .Site.Params.author }}
Title => {{ .Site.Title }}

Or markdown:

Author => {{< param "author" >}}

Although I cannot directly access .Site.Title from the markdown files, I could make a shortcode and then access that if I needed to.

Updating GitHub Actions

Once everything was building locally, then I was ready to update my GitHub Actions so that my site could built on CI/CD.

Jekyll GitHub Action

- name: Install Ruby
  uses: ruby/setup-ruby@v1
  with:
    bundler-cache: true

- name: Jekyll Build
  run: JEKYLL_ENV=production bundle exec jekyll build --destination _site/

Hugo GitHub Action

- name: Install Hugo
  uses: peaceiris/actions-hugo@v3
  with:
    extended: ${{ inputs.hugo_extended }}
    hugo-version: ${{ inputs.hugo_version }}

- name: Hugo Build
  run: HUGO_ENV=production hugo

And, the best part is that I no longer need a Gemfile or Gemfile.lock in every static site project. All I need is to ensure my computer has the latest version of Hugo installed (conveniently managed by Homebrew).

Beyond the Basics

Larger projects (like this site) involved deeper migrations:

  • Photo galleries (PhotoSwipe, Lightbox2)
  • Blog post pagination with tags and collections
  • Custom RSS and sitemap generation
  • SEO metadata and OpenGraph

In Jekyll, these were primarily handled by plugins. In Hugo, I had to manually define templates like layouts/home/feed.xml, layouts/_default/sitemap.xml, and enhanced layouts/partials/head.html for SEO metadata. To handle the pagination, I had to use Hugo’s built in paginator and define it myself:

{{ $pages := where .Site.Pages ".Type" "blog_post" }}

{{ if .Params.tag }}
  {{ $pages = where $pages "Params.tags" "intersect" (slice .Params.tag) }}
{{ else if .Params.category }}
  {{ $pages = where $pages "Params.category" "eq" .Params.category }}
{{ end }}

{{ $paginator := .Paginate $pages }}

.
.
.

{{ range .Paginator.Pages }}
  .
  .
  .
{{ end }}

Results & Reflections

After converting three different projects, I’m glad I made the move. My largest site went from ~4 seconds to build (with Commonmark) to 162 ms with Hugo. That’s a ~25x improvement. Live reloading is nearly instantaneous. I no longer need to manage multiple gem dependencies or plugins—just Hugo itself. While it wasn’t all easy, the internet and some of the latest coding AI tools significantly helped.

Would I recommend Hugo over Jekyll? It depends:

  • Use Jekyll if you’re already deep into Ruby, depend on specific plugins, or need GitHub Pages-native builds.
  • Use Hugo if you want raw speed, Go-style templating, and fewer external dependencies.

Personally, any new site I create will start with Hugo. And if you’re at all interested in learning Hugo, are comfortable with Go, or are looking for a static site generator that’s simpler and faster, I’d encourage you to give Hugo a try.