Migrating from Ghost to Hugo on Vercel

I ran my blog on Ghost for a couple of years. It worked fine. But “worked fine” started to feel like the wrong bar. I was paying for managed hosting on a platform built for publication teams, using maybe 10% of what it offered. Ghost is good software. It is just overkill for a personal blog where one person writes and nobody else needs a CMS.

I wanted something faster, cheaper, version-controlled, and fully mine. Hugo with Vercel was the answer.


Why Hugo

Hugo is a static site generator written in Go. Build times are measured in milliseconds. There is no database, no runtime, no server process — just a public/ directory of HTML files that deploys anywhere.

For a content-heavy personal blog with four distinct streams (Engineering, Notes, Thoughts, Ventures), Hugo’s section-based content model maps cleanly. Each section gets its own archetype, its own list template, and its own front matter defaults. The content is plain markdown files in a git repo, which means the entire blog is versionable and portable.

The theme I chose is shibui: minimal, monospace, no JavaScript by default.


The git submodule trap

Most Hugo theme documentation tells you to add the theme as a git submodule:

git submodule add https://github.com/shenoybr/hugo-shibui themes/shibui

This works locally. It breaks on Vercel.

Vercel clones your repository but does not initialize submodules by default. The theme directory ends up empty, the build fails silently, and you get a blank page. The fix is to vendor the theme: remove the submodule and commit the theme files directly into your repo.

rm -rf themes/shibui/.git
git add themes/shibui
git commit -m "vendor shibui theme"

The theme is now part of the repository, versioned alongside the content.


Content migration from Ghost

Ghost exports all content as a single JSON file from Settings → Labs → Export. The export contains posts, pages, tags, and metadata.

Each post needs to become a Hugo markdown file with front matter. A Python script handles the bulk: parsing the JSON, stripping Ghost’s internal URL tokens (__GHOST_URL__), converting HTML to markdown, and writing files into the correct Hugo section directory.

Ghost slugs live at the root: dineshmannam.com/how-fiat-money-works-rupee/. Hugo section paths are nested: dineshmannam.com/thoughts/money/how-fiat-money-works-rupee/. Every migrated slug needs a redirect.


Deployment on Vercel

Vercel does not have Hugo in its default build environment, so a build.sh script handles the setup:

#!/bin/bash
set -e
curl -L https://github.com/gohugoio/hugo/releases/download/v0.147.9/hugo_extended_0.147.9_Linux-64bit.tar.gz | tar xz
./hugo --minify

vercel.json points to this script and sets the output directory:

{
  "buildCommand": "chmod a+x build.sh && ./build.sh",
  "outputDirectory": "public"
}

Push to main, Vercel builds, deploys. No CI configuration needed.

The Hugo docs have a full walkthrough for this setup: Host on Vercel.


SEO: 301 redirects and DNS cutover

Skipping redirects during a migration is how you lose search rankings. Every Ghost post that had a public URL needs a permanent redirect to its new Hugo path.

Vercel handles this natively in vercel.json:

"redirects": [
  {
    "source": "/how-fiat-money-works-rupee/",
    "destination": "/thoughts/money/how-fiat-money-works-rupee/",
    "permanent": true
  }
]

"permanent": true issues a 301. Google transfers link equity to the new URL and updates its index over time.

For DNS, the domain is managed through Cloudflare. The cutover requires replacing the existing A record (pointing to Ghost) with a CNAME pointing to Vercel:

  • Type: CNAME
  • Name: @
  • Value: <hash>.vercel-dns-017.com (provided by Vercel in the Domains panel)
  • Proxy: DNS only — Vercel needs to provision its own SSL, so turn the orange cloud off

Cloudflare propagates fast. After cutover, submit https://yourdomain.com/sitemap.xml to Google Search Console. Hugo generates the sitemap automatically.


GA4

The shibui theme has no built-in analytics hook, so GA4 goes into a project-level partial that overrides the theme’s <head>. Create layouts/_partials/head.html as a copy of the theme’s version, then append the GA4 snippet wrapped in a production guard:

{{ if hugo.IsProduction }}
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
{{ end }}

hugo.IsProduction keeps the tag off during local hugo server runs. No accidental self-sessions in analytics.


The Claude Cowork angle

Most of the migration work — content conversion, front matter cleanup, redirect mapping, custom layouts, CSS — was done in Claude Cowork. It works as a live working session: reviewing content, catching issues, making edits across files, keeping a running backlog. Less like a code generator, more like pairing with someone who has the full context of the project.

If you have a migration with moving parts, it is worth trying.


One thing I’d do differently

Get the redirects into vercel.json and deployed before cutting over DNS. There is a window between DNS propagation and Vercel deployment where old URLs return 404s if the redirects are not already live. The order matters: deploy redirects first, then cut over.

Everything else is linear. Ghost export → markdown conversion → Hugo structure → Vercel deploy → DNS cutover → Search Console. Each step can be tested in isolation before you move on.

· 4 min read