Migrating from WordPress to Hugo

A practical guide to migrating your WordPress site to Hugo—from exporting content and building templates to handling redirects and deploying.

In the first post of this series, I ranted about WordPress and why I left. Now let’s get practical. This is the step-by-step account of how I actually migrated three websites from WordPress to Hugo—what worked, what didn’t, and what I’d do differently.

Fair warning: this isn’t a “click here, then click there” tutorial. Hugo is a tool for developers. If you’re comfortable with the command line, HTML templates, and Markdown, you’ll be fine. If you’re looking for a drag-and-drop experience, Hugo isn’t it—and that’s kind of the point.

The Game Plan

Before touching any code, I wrote down what each migration needed:

  1. Export WordPress content — posts, pages, metadata
  2. Set up Hugo — project structure, config, templates
  3. Convert content — HTML to Markdown, fix formatting
  4. Build a theme — Bootstrap 5, responsive, dark mode
  5. Add search — because visitors expect it
  6. Handle redirects — don’t break existing URLs
  7. Deploy — rsync with checksums

The order matters. Don’t start building templates before you know what your content looks like.

Exporting from WordPress

Here’s the thing about WordPress exports: the built-in XML export is poorly suited for migration. It gives you an XML file with serialized PHP objects, shortcode markup, and inconsistent HTML.

I went a different route. I exported the MySQL database directly and parsed the SQL dump. The wp_posts table has everything you need—post_title, post_content, post_name (the slug), post_date, and post_status. Filter for post_status = 'publish' and post_type = 'post', and you’ve got your content.

For categories, you need to join wp_terms, wp_term_taxonomy, and wp_term_relationships. It’s not pretty, but it’s predictable.

I wrote a quick script to extract each post into a Markdown file with YAML front matter:

---
title: "Your Post Title"
slug: "your-post-slug"
date: 2024-06-15T10:00:00
description: "A brief description for previews and SEO."
categories:
  - Category Name
---

Post content here in Markdown...

The slug field is important—it controls the URL, and you want it to match your old WordPress permalinks as closely as possible. More on that in the redirects section.

Converting HTML to Markdown

WordPress stores content as HTML. Hugo wants Markdown. This is where things get messy.

Simple posts with paragraphs, headers, and links convert cleanly. But WordPress loves to insert weird markup—empty <p> tags, inline styles, <span> wrappers that do nothing, shortcodes from plugins you forgot you installed.

I used a combination of automated conversion and manual cleanup. The automated pass handled the common patterns: <h2> to ##, <a> to [](), <strong> to **. The manual pass fixed everything the automation missed—broken lists, weird spacing, tables that WordPress had mangled through its visual editor.

For kommandozeile.org, I had dozens of articles with code blocks that needed proper language identifiers added. WordPress had them all as generic <pre><code> blocks. Hugo’s syntax highlighting is miles better, but it needs to know the language:

```bash
grep -r "pattern" /var/log/
```

That manual cleanup took time. But the result is clean Markdown that I can read, edit, and version control—unlike the HTML soup that lived in the WordPress database.

Setting Up Hugo

Hugo’s project structure is straightforward once you understand it:

blog/
├── build.sh           # Build + index + sync
├── content/
│   └── posts/         # Your Markdown files
└── hugo/
    ├── config.toml    # Site configuration
    ├── layouts/       # Templates
    │   ├── _default/  # Base templates
    │   ├── partials/  # Reusable components
    │   └── index.html # Homepage
    └── static/        # CSS, JS, images

The config.toml is minimal. Here’s roughly what mine looks like:

baseURL = "https://blog.dominicrodemer.com/"
languageCode = "en"
title = "Dominic Rodemer's Blog"

uglyURLs = false  # /post-slug/ not /post-slug.html

[pagination]
  pagerSize = 10

[permalinks]
  posts = "/:slug/"

[taxonomies]
  category = "categories"

[markup.highlight]
  codeFences = true
  noClasses = false  # Use CSS classes, not inline styles

The noClasses = false setting for syntax highlighting is worth noting. By default, Hugo inlines highlight styles, which makes your HTML bloated and hard to customize. With CSS classes, you generate a stylesheet once (hugo gen chromastyles --style=alucard) and your code blocks stay clean.

Building Templates

I didn’t use a pre-built Hugo theme. Not because they’re bad—some are great—but because I wanted full control and my layout is simple enough that a theme would’ve been overkill.

Hugo uses a block-based template inheritance model. You define a base template, then override blocks in child templates:

<!-- baseof.html -->
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
  {{ partial "head.html" . }}
</head>
<body>
  {{ partial "header.html" . }}
  <main>
    {{ block "main" . }}{{ end }}
  </main>
  {{ partial "footer.html" . }}
</body>
</html>
<!-- single.html (individual posts) -->
{{ define "main" }}
<article>
  <h1>{{ .Title }}</h1>
  <p>{{ .Description }}</p>
  {{ .Content }}
</article>
{{ end }}
<!-- list.html (post archives, category pages) -->
{{ define "main" }}
{{ range .Paginator.Pages }}
  <a href="{{ .RelPermalink }}">
    <h2>{{ .Title }}</h2>
    <p>{{ .Description }}</p>
  </a>
{{ end }}
{{ end }}

Three template files and a handful of partials. That’s the entire site. Compare that to a WordPress theme with dozens of PHP files, template tags, hooks, filters, and a functions.php that keeps accumulating code you’ll never touch again.

I used Bootstrap 5.3 loaded locally—no CDN dependency. Custom CSS on top for project-specific styling. One tip: use CSS variables for colors, even when Bootstrap provides utility classes. It makes dark mode implementation dramatically easier.

Search was the one thing I worried about losing. WordPress has plugins for it. Hugo generates static files—there’s nothing to query at runtime.

I built a search system using SQLite FTS5 and a thin PHP API that I now use across all three sites. The approach was interesting enough that I wrote a dedicated post about it—covering the indexer, the full API code, the frontend JavaScript with keyboard navigation, Unicode handling for international content, and how the same pattern scales from a single-language blog to a help center with eleven languages.

The Build Pipeline

This is where my setup diverges from the typical “just run hugo” approach. I wrote a build script that chains three steps:

  1. Hugo buildhugo --minify to a temp directory
  2. Search indexing — custom indexer creates the SQLite database
  3. Smart syncrsync --checksum to the output directory

The checksum-based sync is the secret sauce. Regular rsync uses timestamps to decide what to copy. But Hugo regenerates every file on every build—even if the content hasn’t changed—which means the timestamps always update. Checksum mode compares actual file contents, so only truly changed files get new timestamps.

Why does this matter? Because it preserves timestamps. Files that didn’t change keep their original modification time. That means when I deploy to my server, I can use a regular time-based rsync—no --checksum flag needed—and it still only transfers the files that actually changed. Fast local sync, fast remote sync.

# Build to temp directory
hugo --minify --source hugo/ --destination "$TEMP_DIR"

# Index content for search
hugo-search-indexer --content content/ --output "$TEMP_DIR/data/search.sqlite"

# Sync only changed files
rsync -a --checksum --delete "$TEMP_DIR/" "website/"

The --dry-run flag lets me preview what would change before committing. I use it every time.

Handling Redirects

This is the part people skip, and it’s the part that matters most for SEO. If your WordPress site had any traffic at all, search engines have indexed your URLs. Break those URLs and you lose whatever ranking you had.

WordPress uses URL patterns like:

  • /2024/06/15/post-slug/ (date-based)
  • /tutorials/post-slug/ (custom structure)
  • /category/category-name/ (category archives)

Hugo generates cleaner URLs:

  • /post-slug/ (just the slug)
  • /categories/category-name/ (taxonomy pages)

Every old URL needs a 301 redirect to its new location. I handled this in .htaccess:

# Old date-based WordPress URLs
RedirectMatch 301 "^/\d{4}/\d{2}/\d{2}/(.+)$" "/$1"

# Old category structure
RedirectMatch 301 "^/category/(.+)$" "/categories/$1"

# Renamed slugs (if any changed during migration)
Redirect 301 "/tutorials/old-slug/" "/new-slug/"

For kommandozeile.org, I also renamed a dozen slugs during migration—removing “linux” from titles to make the content platform-generic. Each rename got its own redirect rule.

Test your redirects. Seriously. Crawl your old sitemap and verify every URL lands somewhere sensible.

Going Live

Deployment is anticlimactic. That’s how it should be.

rsync -avz --delete website/ server:/var/www/blog/

Changed files get uploaded. Everything else stays untouched. No database to migrate. No plugins to activate. No caches to warm. The first page view is just as fast as the millionth.

One gotcha I hit: file permissions. Files created locally defaulted to 600 (owner-only read/write). The web server needs 644 for files and 755 for directories. A quick chmod pass after the first upload fixed it. I added a note to my deployment checklist so I wouldn’t forget again.

What I’d Do Differently

If I were starting over:

Invest more time in content conversion upfront. I rushed the HTML-to-Markdown step and kept finding formatting issues afterwards. A thorough conversion script saves time in the long run.

Set up redirects before going live, not after. I had a brief window where old URLs returned 404s. Nobody probably noticed, but it bugged me.

Start with the content, not the theme. I spent hours tweaking CSS before I had all my content migrated. The content should drive the design, not the other way around.

The Bottom Line

Migrating from WordPress to Hugo isn’t hard. It’s tedious in places—content conversion, redirect mapping, template building—but none of it is complicated. It’s just work. Methodical, predictable work.

The payoff is a site that’s faster, simpler, and entirely under your control. No plugins to update, no database to maintain, no cache to clear. Just Markdown files, a build command, and static HTML.

Next up in the series: how I built search for static sites using SQLite FTS5, and then the most challenging migration of the three—converting a WordPress help center with eleven languages to Hugo.