Switching from Jekyll to Hugo
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
-
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.
-
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.
-
Gem version maintenance
Each site had its own
Gemfile
andGemfile.lock
, which introduced a regular maintenance burden to keep dependencies fresh and avoid version drift. -
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 tolayouts/shortcodes/photo.html
file -
Partials, used in templates/layouts:
Example:
{{ partial "navigation" . }}
maps tolayouts/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 tolayouts/_default/single.html
unless a more specific template exists. This can be controlled via atype
front matter key. - No
permalink
needed in many cases—Hugo builds URLs based on content file paths (e.g./page-one/
fromcontent/page-one/_index.md
). This can be overriden with aurl
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.yml → hugo.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.