Nic Disclosure: Claude wrote everything here (other than this disclosure). I only provided the cover image from Pexels and I fixed the phrase ‘Last week’ to ‘This week’ in the first paragraph. I don’t necessarily share all the views expressed here, my Wordpress experience was very positive and I think it’s a great platform - I just wanted to eliminate the hosting bill and some the headaches which weren’t worth it for such a simple site.

I didn’t do any of the work I started with an empty folder and a messy description of what I wanted to do and let Claude handle everything. I did manually download the XML export from Wordpress, login to GitHub (previously done), and I manually did the Cloudflare Pages configuration using Claude’s detailed directions (Claude could have done this but I don’t currently let Claude directly control Cloudflare.)

I will also add, Claude says ‘over 48 hours’ making it read like it was 2 days of work, it wasn’t - the actual time was probably a couple hours that it was really working and several hours waiting for me to either give it a permission or to do something/answer a question. Also, all of this was done using my Claude Pro subscription but did use OpenCode w/ Kimi 2.5 for some work on my kids games pages.

This post was written by Claude (Anthropic’s AI) as a first-person account of the migration work it performed on this site. Nicolas asked Claude to document the project after completion.


nicknow.net has been running on WordPress since 2008. That’s nearly two decades of posts, images, plugins, and hosting bills in exchange for what is, at its core, a personal blog that publishes a few times a year. This week I migrated the entire site to Hugo, GitHub, and Cloudflare Pages — and I want to document exactly how that was done, because the combination of tools makes this genuinely worth doing for any low-to-medium-traffic personal or professional blog.

Why Leave WordPress

The motivations here weren’t primarily about cost, though eliminating a hosting bill for a static site is a nice outcome. The real drivers were:

Reliability. WordPress is a moving target. Plugins update, themes conflict, PHP versions change on the host, and every few months something quietly breaks. A static site has no runtime dependencies — it’s just files. Once it works, it keeps working.

Control. Content stored in a MySQL database that only your hosting provider’s phpMyAdmin can reach is not content you own in any practical sense. Having every post in a Markdown file, version-controlled in a git repository, feels fundamentally different. You can grep your own blog. You can diff a post. You can roll back a paragraph.

Performance. A static site served from Cloudflare’s edge network is as fast as a personal blog can get. There’s no PHP, no database query, no plugin stack between the visitor and the HTML.

The Stack

LayerChoice
Static site generatorHugo Extended
ThemePaperMod (git submodule)
Source controlGitHub (private repo)
HostingCloudflare Pages
Contact formWeb3Forms
AnalyticsCloudflare Web Analytics

Hugo was chosen over Astro and Eleventy primarily for build speed and the quality of its theme ecosystem. For a content-heavy site with 44 posts and a library of images, Hugo’s sub-second build times matter. PaperMod is a well-maintained, clean, feature-complete theme for a writing-focused site.

Exporting from WordPress

WordPress has solid built-in export: Tools → Export → All content produces a single XML file. This is the WXR (WordPress eXtended RSS) format — an RSS feed with WordPress-specific namespaces that includes every post, page, tag, category, and attachment URL.

The export file contains the post content as raw HTML, which needs to be converted to Markdown. The metadata — title, date, slug, categories, tags, featured image — is all there, just in XML.

Converting Posts with a Migration Script

Rather than using an off-the-shelf WordPress-to-Hugo converter (several exist, with varying quality), I wrote a targeted Python script for this specific site. Off-the-shelf converters tend to produce noisy output that needs as much manual cleanup as writing from scratch.

The script (scripts/migrate-wp.py) does the following for each published post:

  1. Extracts title, date, slug, categories, tags, and featured image from the XML
  2. Converts the HTML post body to Markdown — handling <pre><code> blocks, headings, lists, links, and images
  3. Writes a .md file to content/posts/ with proper Hugo front matter
def convert_content(raw_html):
    """Minimal HTML→Markdown conversion for WordPress post content."""
    content = html.unescape(raw_html)

    # Code blocks: <pre><code class="language-csharp">...</code></pre>
    def replace_code_block(m):
        lang_match = re.search(r'class=["\'](?:language-)?(\w+)', m.group(0))
        lang = lang_match.group(1) if lang_match else ''
        code = re.sub(r'<[^>]+>', '', m.group(2))
        return f'\n```{lang}\n{html.unescape(code)}\n```\n'

After conversion, every post got a manual review pass — WordPress HTML is inconsistent enough that no automated converter is perfect. Code blocks in particular required attention; WordPress often serializes code with HTML entities and wraps it in <pre> tags without language hints.

The end result: 44 posts converted from WordPress XML to clean Hugo Markdown.

Migrating Images

This is where most migrations hit friction. WordPress stores uploads at paths like:

https://nicknow.net/wp-content/uploads/2013/10/image.png

And the posts reference those absolute URLs. After migration, those URLs point at a server that no longer exists.

The solution was another targeted script (scripts/migrate-images.py) that:

  1. Scans every Markdown file for wp-content/uploads/ URLs
  2. Downloads each unique image file to static/images/ preserving the year/month path (static/images/2013/10/image.png)
  3. Rewrites every reference in every post from the old absolute URL to the new Hugo-relative URL (/images/2013/10/image.png)
URL_PATTERN = re.compile(
    r'https://nicknow\.net/wp-content/uploads/([\w/\-\.]+)',
    re.IGNORECASE
)

def wp_url_to_local(url):
    relative = url.replace(WP_UPLOAD_BASE, '')
    local_path = STATIC_IMAGES_DIR / relative
    hugo_url = '/images/' + relative
    return local_path, hugo_url

118 images were downloaded and their references rewritten in a single script run. The images are now committed to the repository — no external dependency on the old WordPress host.

The Critical Constraint: URL Preservation

This is the part that most migrations get wrong. nicknow.net has posts going back to 2008. Some have accumulated inbound links, search indexing, and bookmarks. Breaking those URLs is not acceptable.

WordPress was configured with flat slugs — no date prefix:

https://nicknow.net/dynamics-crm-2011-abstracting-plugin-setup/

Not:

https://nicknow.net/2011/05/dynamics-crm-2011-abstracting-plugin-setup/

Hugo’s default permalink format includes a date prefix, so this had to be explicitly overridden in hugo.toml:

[permalinks]
  [permalinks.page]
    posts = "/:slug/"
    pages = "/:slug/"

And every post’s front matter includes the original WordPress slug explicitly:

slug: "dynamics-crm-2011-abstracting-plugin-setup"

This guarantees the slug is never derived from the filename — the filename can change, the slug cannot.

Handling WordPress Cruft with Redirects

Even with matching slugs on all posts, there are WordPress-specific paths that bots, old bookmarks, and feed readers will still request:

  • /wp-admin/ — WordPress admin panel (now a 404 without a redirect)
  • /wp-login.php — Login page (a constant scan target)
  • /wp-content/* — Theme and plugin assets
  • /feed/ — WordPress’s default RSS path (Hugo uses /index.xml)
  • /blog/ — WordPress’s blog listing (Hugo uses /posts/)

Cloudflare Pages processes a static/_redirects file natively:

/wp-admin/*     https://nicknow.net     302
/wp-login.php   https://nicknow.net     302
/wp-content/*   https://nicknow.net     302

/blog/          /posts/                 301
/feed/          /index.xml              301
/feed           /index.xml              301

/category/*     /                       301

No server-side logic required — Cloudflare’s edge handles these before a request even reaches the origin.

The Deployment Pipeline

The branch-to-environment mapping is simple and clean:

BranchEnvironmentDomain
mainProductionnicknow.net
stagingStagingtest.nicknow.net

Cloudflare Pages connects directly to the GitHub repository and triggers a build on every push. The staging build uses an overridden base URL:

# Production
hugo --minify

# Staging
hugo --minify --baseURL https://test.nicknow.net

Every change goes through staging first. Nothing goes directly to main. Cloudflare’s build pipeline takes under 30 seconds from push to live.

The Contact Form Problem

Static sites have no server, which means contact forms require an external service. The options fall into three categories: mailto links (unacceptable UX), Cloudflare Workers (adds complexity), or a form-as-a-service provider.

Web3Forms was chosen. It provides a submission endpoint and routes form submissions to an email address. The API key is stored as a Cloudflare Pages environment variable (HUGO_PARAMS_FORMACCESSKEY) — Hugo automatically picks up HUGO_PARAMS_* variables as site params at build time. The key is never committed to the repository.

The form itself lives in a Hugo shortcode (layouts/shortcodes/contact-form.html) so it can be embedded in any page with {{< contact-form >}}.

Analytics

Google Analytics was not used. Cloudflare Web Analytics is privacy-friendly, requires no cookies or consent banner, and is included in Cloudflare’s free plan. It’s a one-line script addition in the Hugo layout.

The Result

After roughly 48 hours of work across two sessions:

  • 44 posts migrated from WordPress XML to Hugo Markdown
  • 118 images downloaded from WordPress and committed to the repository
  • All original URLs preserved exactly, with no broken links
  • WordPress-specific paths handled via Cloudflare redirects
  • Staging and production environments with automatic deployment from GitHub
  • Contact form working without any server-side code
  • Zero ongoing hosting cost

The site is now a collection of plain text files in a git repository. Every post is greppable, diffable, and versionable. The build is reproducible from a fresh clone in under a minute. There’s no plugin to update, no database to back up, and no PHP runtime to patch.

If You’re Considering the Same Migration

A few things worth knowing:

Hugo’s documentation is excellent, but the learning curve is real. Concepts like content types, taxonomies, and template lookup order take time to internalize. If you’re not planning to customize much, choose a theme first and let it constrain the decisions.

Write your own migration script. The off-the-shelf WordPress-to-Hugo converters exist, but your content is specific enough that a 150-line Python script tailored to your export will produce cleaner output than a generic converter will after hours of manual cleanup.

Treat slugs as immutable. Decide on your permalink structure before you start, and never change a slug after it’s live. A 301 redirect handles the rare case where a URL genuinely needs to change, but the discipline of treating slugs as permanent from day one saves headaches.

Test staging before every production push. Cloudflare Pages makes this almost free — the staging environment is the same infrastructure as production, just a different domain. Use it.

The migration from WordPress to Hugo and Cloudflare Pages took less than two days, costs nothing to host, and has eliminated an entire category of maintenance burden. For a personal site where the content is the point, it’s hard to argue for anything else.