Migrating a Docs Site From Jekyll to Hugo

Jekyll is where a great many documentation sites started, and it stops being comfortable in a predictable way: builds that take minutes, a plugin list that only one person understands, and a hosting default that limits what you can run. Hugo is the usual destination because the content model is close enough that most Markdown converts mechanically, and the build-speed difference on a large corpus is not subtle.

This guide is the concrete path: mapping collections to sections, converting Liquid to Go templates and shortcodes, reproducing permalinks exactly, and the measured before and after on a 1,200-page site. It is one route through the general process in Migrating Between Static Site Generators.

Prerequisites

  • Hugo extended 0.128 or newer (hugo version — the extended build is required for SCSS and image processing).
  • The Jekyll site building locally, so you can diff old output against new.
  • The production URL list from your sitemap, saved before you start.
  • Node available for the conversion script, or Ruby if you prefer to write it against Jekyll's own front-matter parser.

Map the Content Model First

Jekyll's collections and Hugo's sections describe the same idea with different mechanics. Jekyll declares collections in _config.yml and stores them in underscore-prefixed directories; Hugo infers a section from any directory under content/, and a directory containing an _index.md becomes a list page.

Jekyll collections mapped to Hugo sections A mapping diagram. Jekyll's underscore posts directory maps to Hugo's content slash posts directory. Jekyll's underscore docs collection maps to content slash docs with an underscore index file for the list page. Jekyll's underscore includes maps to Hugo layouts partials, and underscore layouts maps to Hugo layouts default. Same concepts, different directory contract Jekyll Hugo _posts/2024-01-01-x.md content/posts/x.md _docs/ collection content/docs/ + _index.md the list page _includes/note.html layouts/partials/note.html or shortcodes/ if used in content _layouts/default.html layouts/_default/baseof.html The date prefix moves out of the filename and into front matter — Hugo derives URLs from config, not from names
Most of the migration is this table applied file by file. The interesting decisions are what happens to page order and to anything that was implicit in a Jekyll filename.

The important behavioural difference is where ordering comes from. Jekyll orders posts by the date in the filename; Hugo orders by a date or weight field. Docs sites almost always want weight, since chapter order is not chronological — so decide the ordering field during conversion and write it into the front matter rather than discovering it later.

The Conversion Script

// scripts/jekyll-to-hugo.mjs — run into a clean directory, never hand-edit the output
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
import { globSync } from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';

await rm('content', { recursive: true, force: true });
let converted = 0, unhandled = [];

for (const src of globSync('_docs/**/*.md')) {
  const { data, content } = matter(await readFile(src, 'utf8'));
  const base = path.basename(src).replace(/^(\d{4}-\d{2}-\d{2})-/, '').replace(/\.md$/, '');

  const body = content
    .replace(/\{%\s*highlight\s+(\w+)[^%]*%\}/g, '```$1')
    .replace(/\{%\s*endhighlight\s*%\}/g, '```')
    .replace(/\{\{\s*site\.baseurl\s*\}\}/g, '')
    .replace(/\{%\s*include\s+note\.html\s+content=["'](.+?)["']\s*%\}/g, '{{< note >}}$1{{< /note >}}')
    .replace(/\{%\s*link\s+(\S+)\s*%\}/g, (_, p) => '/' + p.replace(/\.md$/, '/'));

  for (const [tag] of body.matchAll(/\{[%{][^}]*[%}]\}/g)) unhandled.push(`${src}: ${tag}`);

  await mkdir('content/docs', { recursive: true });
  await writeFile(path.join('content/docs', `${base}.md`), matter.stringify(body, {
    title: data.title,
    description: data.description ?? data.excerpt,
    weight: data.nav_order ?? data.weight ?? 100,
    date: data.date,
    draft: data.published === false,
    aliases: data.permalink ? [data.permalink] : undefined,
  }));
  converted++;
}

console.log(`converted ${converted} file(s)`);
if (unhandled.length) console.log('unhandled template tags:\n' + unhandled.join('\n'));

The unhandled report is the progress bar for the whole migration — every line is a Liquid construct that still needs a rule or a manual decision in the source. Note the aliases entry: Hugo emits a redirect stub for each alias, which is the cheapest way to honour a Jekyll permalink that does not match the new structure.

Reproduce the URLs Exactly

Hugo derives URLs from configuration, so a Jekyll permalink pattern becomes a permalink setting per section:

# hugo.yaml
baseURL: https://example.com/
languageCode: en-us
permalinks:
  posts: /blog/:year/:month/:slug/    # matches Jekyll's /blog/:categories/:year/:month/:day/:title/
  docs: /docs/:slug/
markup:
  goldmark:
    renderer:
      unsafe: true                    # allow raw HTML that Jekyll permitted
  highlight:
    noClasses: false
    lineNos: false

unsafe: true deserves a note: Jekyll's Kramdown passes raw HTML through by default, so any content with inline HTML silently renders empty in Hugo without it. It is the single most common "the page is missing half its content" cause after a Jekyll migration.

Then gate on parity in CI, comparing the built tree against the sitemap you saved:

hugo --minify --gc
find public -name '*.html' | sed 's|public||; s|/index.html|/|' | sort > urls-after.txt
comm -23 urls-before.txt urls-after.txt > missing.txt
[ -s missing.txt ] && { echo "MISSING URLS:"; cat missing.txt; exit 1; }
echo "URL parity: OK"

Anything in missing.txt needs either a permalink rule or an alias — the mechanics of which are covered in Keeping Redirects Working After an SSG Migration.

Convert Includes to Partials or Shortcodes

Jekyll's {% include %} covers two different jobs that Hugo separates. An include used inside a layout becomes a partial; an include used inside content becomes a shortcode. Getting this wrong is why some converted pages render literal braces.

{{/* layouts/shortcodes/note.html — used in content as {{< note >}}text{{< /note >}} */}}
<aside class="note">
  <strong>Note:</strong> {{ .Inner | markdownify }}
</aside>
{{/* layouts/partials/toc.html — used in layouts as {{ partial "toc.html" . }} */}}
{{ if gt (len .TableOfContents) 40 }}
  <nav class="toc" aria-label="On this page">{{ .TableOfContents }}</nav>
{{ end }}

The census from phase 1 tells you which are worth porting; the general mapping table is in Porting Shortcodes and Includes Between Generators.

Replace the Asset Pipeline Deliberately

Jekyll sites usually build CSS through Sass and ship JavaScript as static files, sometimes with a plugin doing minification. Hugo Pipes covers all of it natively, and the conversion is a good moment to delete a build step rather than port it.

Asset pipeline before and after Two pipelines. The Jekyll pipeline runs jekyll-sass-converter, a minifier plugin and a manual fingerprinting step, producing three separate tools to maintain. The Hugo pipeline chains resources.Get, toCSS, minify and fingerprint in a single template expression with no plugins. Three plugins become one template expression Jekyll sass-converter minifier plugin manual fingerprinting Hugo Pipes resources.Get toCSS minify fingerprint No gems to install, no version drift, and the integrity hash comes out of the same chain The output is byte-identical to what the plugin chain produced — verify with a checksum before deleting the old one
The whole chain is one expression, evaluated at build time, with the fingerprint available to the template that emits the link tag.
{{ $css := resources.Get "scss/main.scss" | toCSS (dict "outputStyle" "compressed")
           | minify | fingerprint "sha384" }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous">

The fingerprinted filename is what lets you cache the file immutably at the edge, which pairs with the policies in CDN Caching Rules for SSGs. Images follow the same pattern through page bundles: put the image next to its Markdown, and .Resources.GetMatch gives you a resource you can resize and convert at build time without a plugin.

Measured Impact

A 1,200-page documentation site with 40 images per section, measured with hyperfine on a warm cache, GitHub Actions ubuntu-latest runner:

MetricJekyll 4.3Hugo 0.128 extendedChange
Full build (cold)96.4 s3.4 s28× faster
Rebuild after one page edit21.8 s0.31 s70× faster
CI job wall clock (incl. install)4 m 10 s51 s−79%
Dependencies installed62 gems1 binary
Output size88 MB84 MB−5%
Build time before and after the migration A horizontal bar chart on a shared scale. The Jekyll cold build takes 96.4 seconds and the Hugo cold build 3.4 seconds. The Jekyll single-page rebuild takes 21.8 seconds and the Hugo rebuild 0.31 seconds, which is a thin sliver at this scale. 1,200 pages, same content, same runner Jekyll cold build 96.4 s Hugo cold build 3.4 s · 28× faster Jekyll one-page rebuild 21.8 s Hugo one-page rebuild 0.31 s Shared linear scale · hyperfine, 10 runs, warm cache, GitHub Actions ubuntu-latest
The rebuild row is what writers feel. Twenty-two seconds per save is a workflow where people batch their edits; a third of a second is one where they do not think about the build at all.

The CI row matters for a different reason: dropping from 62 gems to a single binary removed an entire class of dependency failure, and made cache warming almost irrelevant — the pattern discussed in Caching Hugo Builds in GitHub Actions.

Pitfalls & Rollback

  • Forgetting unsafe: true. Raw HTML in Markdown disappears silently. If pages look half-empty after conversion, check this first.
  • Losing page order. Jekyll's filename dates carry ordering that Hugo ignores. Write weight during conversion.
  • {% link %} and {{ site.* }} left in content. They render as literal text. The unhandled-tag report catches them all.
  • Assuming Kramdown and Goldmark agree. Definition lists, footnote syntax and some table edge cases differ; check a page of each type early.
  • Deleting the Jekyll site too soon. Keep it building until the last section has been cut over and watched for a week.
  • Rollback: until the DNS or edge route changes, the Jekyll site is still live and authoritative. After cutover, rolling back is re-pointing the route — which is why staging by path prefix is worth the setup.

Conclusion

A Jekyll-to-Hugo migration is mostly mechanical: collections become sections, includes split into partials and shortcodes, and permalinks move from filenames into configuration. The work that is not mechanical is the Liquid inside content, which a conversion script can find for you exhaustively. Gate on URL parity, keep the old site building until every section is cut over, and expect the build-time difference to change how writers work more than it changes how the site performs. The general framing is in Migrating Between Static Site Generators.

FAQ

Does Hugo read Jekyll front matter as is?

Mostly. YAML front matter between triple dashes parses directly, and title, date and draft mean the same thing in both. What differs is everything Jekyll-specific: permalink patterns, categories used as paths, and layout names that must map to Hugo template names.

What happens to my Liquid tags?

They will not render. Hugo uses Go templates, so highlight blocks, include tags and any Liquid logic in content must be converted to fenced code blocks, shortcodes or plain Markdown. A conversion script handles the common ones; the rest show up as visible braces in the build output, which makes them easy to find.

How do I keep my Jekyll URLs?

Set permalinks per section in the Hugo config to match Jekyll's pattern, and add an aliases entry to any page whose path genuinely has to change. Verify with a URL diff between the old sitemap and the new build before any traffic moves.

Is Hugo faster in practice or only in benchmarks?

In practice, and the gap widens with page count. A 1,200-page docs site measured here went from 96 seconds to 3.4 seconds for a full build, because Hugo renders in parallel and does not pay Ruby's per-page overhead.

What do I lose by leaving Jekyll?

The Ruby plugin ecosystem and the GitHub Pages default build. Most plugin functionality exists natively in Hugo, but anything genuinely custom has to be rewritten as a template or a build step, and you will need your own CI pipeline to deploy.