I shared how I transformed my old laptop into a home server. That experimental setup became the perfect testing ground for something I'd been curious about — migrating from WordPress to Hugo. What started as a weekend project has turned into this comprehensive guide.
Why Leave WordPress?
As someone obsessed with page load speeds, WordPress has always been a mixed bag. Sure, it's powerful, but between PHP generating pages on-the-fly, database queries, and plugin overhead, achieving true speed is a constant battle.
Every tenth of a second matters for SEO and UX. Studies show 50% of visitors abandon slow pages. While I'd optimized my WordPress site with OpenLiteSpeed and premium hosting, I knew there was untapped potential.
Hugo's advantages:
⚡ Static files serve faster than dynamic PHP
🔒 No database or plugins to patch constantly
✍️ Write in Markdown, deploy anywhere
🚀 Minimal maintenance overhead
The biggest concern? Migration horror stories — lost images, broken links, frustrated bloggers giving up halfway. But with the right approach, these pitfalls are avoidable.
Understanding Hugo's Core Concepts
Hugo is a static site generator that transforms Markdown into HTML. Unlike WordPress (dynamic generation on each visit), Hugo pre-builds everything once.
Key components:
Content directory: Articles and pages in Markdown
Layouts/Themes: Templates for appearance
Static directory: Images, CSS, JS served as-is
Each Markdown file starts with "front matter" — metadata similar to WordPress custom fields but cleaner:
---
title: "My Article"
date: 2025-01-20
tags: ["webdev", "performance"]
---
Installation and Setup
Install Hugo Extended
The "Extended" version is essential for modern themes with Sass support:
cd /usr/local/bin
wget https://github.com/gohugoio/hugo/releases/latest/download/hugo_extended_0.157.0_Linux-64bit.tar.gz
tar -xzf hugo_extended_0.157.0_Linux-64bit.tar.gz
rm hugo_extended_0.157.0_Linux-64bit.tar.gz
hugo version
Create Your Site
cd ~/projects
hugo new site my-hugo-site
cd my-hugo-site
Add Hextra Theme
Modern, fast, and clean:
git init
git submodule add https://github.com/imfing/hextra themes/hextra
cp themes/hextra/hugo.toml ./hugo.toml
Configure hugo.toml:
baseURL = "https://your-domain.com/"
title = "Your Site Title"
theme = "hextra"
Test Locally
hugo server -D --bind=0.0.0.0
Visit http://localhost:1313
Migration Process
Export from WordPress
Dashboard → Tools → Export → All Content
This generates a WXR file with all your content. Transfer it to your Hugo server.
Convert to Markdown
Rather than unreliable automated tools, here's a custom Python script using Pandoc.
First create convert_wp_to_hugo.py:
#!/usr/bin/env python3
import os, subprocess, re
from lxml import etree
from datetime import datetime
# Install dependencies first:
# apt install -y python3 python3-venv pandoc
# python3 -m venv venv && source venv/bin/activate
# pip install lxml pyyaml
INPUT_XML = "/path/to/wordpress-export.xml"
OUTPUT_DIR = "content/posts"
os.makedirs(OUTPUT_DIR, exist_ok=True)
def to_slug(s):
return "".join(c.lower() if c.isalnum() or c in "-" else "-" for c in s).strip("-")
def yaml_safe(s: str) -> str:
if re.search(r"[:#\-\?\[\]\{\},&\*!\|>'\"%@`]|^\s|\s$", s) or " " in s:
return f'"{s.replace(\'"\', \'\\"\'}"'
return s
root = etree.parse(INPUT_XML, parser=etree.XMLParser(encoding="utf-8"))
for item in root.xpath("//item"):
status = item.findtext("{http://wordpress.org/export/1.2/}status") or ""
post_type = item.findtext("{http://wordpress.org/export/1.2/}post_type") or "post"
if status != "publish" or post_type not in {"post", "page"}:
continue
title = (item.findtext("title") or "Untitled").strip()
slug = item.findtext("{http://wordpress.org/export/1.2/}post_name") or to_slug(title)
date = item.findtext("{http://wordpress.org/export/1.2/}post_date") or datetime.utcnow().isoformat()
html = item.findtext("{http://purl.org/rss/1.0/modules/content/}encoded") or ""
# Convert HTML to Markdown via Pandoc
p = subprocess.run(
["pandoc", "-f", "html", "-t", "gfm-smart", "--wrap=none"],
input=html.encode("utf-8"), capture_output=True
)
md = p.stdout.decode("utf-8").strip()
# Extract categories and tags
categories = [cat.text.strip() for cat in item.findall("category")
if cat.get("domain") == "category" and cat.text]
tags = [cat.text.strip() for cat in item.findall("category")
if cat.get("domain") == "post_tag" and cat.text]
# Build front matter
fm = ["---", f'title: "{title.replace("\\", "\\\\").replace('"', '\\"')}"',
f"slug: {slug}", f"date: {date}", "draft: false"]
if categories:
fm.append("categories:")
for c in categories:
fm.append(f" - {yaml_safe(c)}")
if tags:
fm.append("tags:")
for t in tags:
fm.append(f" - {yaml_safe(t)}")
fm.extend(["---", ""])
out_name = f"{date[:10]}-{slug}.md" if post_type == "post" else f"{slug}.md"
with open(os.path.join(OUTPUT_DIR, out_name), "w", encoding="utf-8") as f:
f.write("\n".join(fm) + "\n" + md + "\n")
print(f"✓ Conversion complete → {OUTPUT_DIR}")
Run it:
python3 convert_wp_to_hugo.py
Image Optimization
Serving modern image formats (AVIF/WebP) dramatically improves performance.
Image Conversion Script
Here is the code:
#!/usr/bin/env python3
import os, re, requests
from pathlib import Path
from PIL import Image
from io import BytesIO
import pillow_avif
# pip install pillow pillow-avif-plugin requests
MD_DIR = Path("content")
OUT_DIR = Path("static/images")
OUT_DIR.mkdir(parents=True, exist_ok=True)
def save_formats(img_bytes, base_path):
"""Save image in multiple formats"""
try:
img = Image.open(BytesIO(img_bytes))
img.save(f"{base_path}.avif", "AVIF", quality=80)
img.save(f"{base_path}.webp", "WEBP", quality=80)
img.save(f"{base_path}.jpg", "JPEG", quality=85)
print(f"✓ {base_path.name}")
except Exception as e:
print(f"✗ Error: {e}")
# Find all image URLs in Markdown
img_pattern = re.compile(r'!\[([^\]]*)\]\((https?://[^)]+)\)')
for md_file in MD_DIR.rglob("*.md"):
content = md_file.read_text(encoding="utf-8")
urls = {url for _, url in img_pattern.findall(content) if url.startswith("http")}
for url in urls:
try:
filename = url.split("?")[0].split("/")[-1]
base_path = OUT_DIR / Path(filename).stem
r = requests.get(url, timeout=30)
r.raise_for_status()
save_formats(r.content, str(base_path))
except Exception as e:
print(f"Failed {url}: {e}")
print(f"\n✓ All images processed → {OUT_DIR}")
Hugo Image Shortcode
Create layouts/shortcodes/img.html:
{{ $src := .Get "src" }}
{{ $alt := .Get "alt" | default "" }}
<picture>
<source type="image/avif" srcset="{{ $src }}.avif" />
<source type="image/webp" srcset="{{ $src }}.webp" />
<img src="{{ $src }}.jpg" alt="{{ $alt }}" loading="lazy" />
</picture>
Use in Markdown:
{{< img src="/images/my-screenshot" alt="Description" >}}
Update Markdown Files
Replace standard image syntax with shortcode:
#!/usr/bin/env python3
import os, re
pattern = re.compile(r'!\[([^\]]*)\]\((https?://[^)]+)\)')
for root, _, files in os.walk("content"):
for filename in files:
if not filename.endswith('.md'):
continue
filepath = os.path.join(root, filename)
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
def replacement(match):
alt_text, url = match.groups()
image_name = os.path.splitext(os.path.basename(url))[0]
return f'{{{{< img src="/images/{image_name}" alt="{alt_text}" >}}}}'
new_content = pattern.sub(replacement, content)
if new_content != content:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"✓ Updated: {filepath}")
Deployment
Build and deploy your static site:
hugo # Generates site in public/
cp -r public/* /var/www/html/
Your site is now pure HTML — no PHP, no database queries, just blazing-fast static content.
Pre-Migration Checklist
Backup everything: Full WordPress files + database export. Test restore separately.
Plan URL structure: Set up redirects from old WordPress URLs to Hugo structure for SEO.
Test thoroughly: Multiple browsers and devices, especially mobile performance.
Lost features: WordPress search, comments, contact forms need alternatives (Algolia, Disqus, Netlify Forms).
Security: Even static sites need proper SSL and security headers.
Performance Optimizations
For maximum speed:
- Use Hugo's
partialCachedfor expensive operations - Implement Hugo Pipes for asset bundling
- Consider Page Bundles for better organization
- Set up CI/CD for automated deployments
Common Issues
"try not defined" errors: Hugo version too old. Update to latest Extended version.
404 on production: Build with hugo and copy public/ folder to web server.
Can't access from network: Add --bind=0.0.0.0 and check firewall settings.
Results
After migrating to Hugo, my site loads in under 200ms — a dramatic improvement from WordPress. Page speeds that once required extensive optimization now come naturally.
The maintenance burden has practically disappeared. No more plugin updates, security patches, or database optimization. Just write in Markdown and deploy.
Conclusion
After completing this migration and running extensive performance tests (detailed in my previous article "WordPress vs Hugo: When Reality Challenges the Speed Myths"), I'll be honest: Hugo didn't convince me as much as I expected.
The performance gains? On paper, Hugo should dominate. In practice, with a properly optimized WordPress setup (OpenLiteSpeed + LS Cache), the difference was only 32ms for content-heavy pages. Hugo's advantage becomes clear at massive scale, but for most sites, a well-configured WordPress can match it.
What I learned:
- Static isn't automatically faster - my Hugo pages had significant HTML parsing overhead
- WordPress with good caching essentially becomes a static site generator
- The real Hugo advantage is simplicity and predictability, not raw speed
- You'll miss WordPress's admin interface more than you think
Was it worth it? As an experiment, absolutely. I learned valuable lessons about web performance, infrastructure, and the real trade-offs between platforms. The migration process itself was enriching.
Should you migrate? Only if you value simplicity over flexibility, are comfortable with Markdown workflows, and don't need dynamic features. A properly optimized WordPress might serve you just as well.
The future probably belongs to hybrid approaches anyway - static generation where it makes sense, dynamic features where needed. Both platforms are evolving toward this middle ground.
Have questions about the migration or want to discuss the performance results? Drop them in the comments below!
Have questions about the migration? Drop them in the comments below!
Resources
Follow me for more web development tutorials and infrastructure guides!
📬 Want essays like this in your inbox?
I just launched a newsletter about thinking clearly in tech — no spam, no fluff.
Subscribe here: https://buttondown.com/efficientlaziness
Efficient Laziness — Think once, well, and move forward.
Top comments (0)