Why leave WordPress#

The weight of running a dynamic CMS#

WordPress is an extraordinary piece of software that powers nearly half the internet. That said, keeping a WordPress installation healthy is a job that never ends. Every visit to your site triggers a chain of events: the server receives the request, PHP wakes up, queries MySQL, assembles the page on the fly, and sends the HTML back to the browser. Multiply that by a hundred simultaneous visitors and you have a server sweating to deliver pages that, on most blogs, are exactly the same for everyone.

And then there’s everything else. Plugins need constant updates — not because developers enjoy shipping new versions, but because every plugin is a potential entry point for anyone looking to break in. WordPress itself requires regular updates for the same reason. The database grows, accumulating post revisions, expired transients, and metadata from plugins you uninstalled two years ago. The server needs the right PHP version, the right extensions enabled, and enough memory to stay upright when Google decides to crawl everything at once. Anyone who runs WordPress knows the question isn’t whether something will break — it’s when.

Add to that the appetite of content scrapers, both the ones feeding AI training pipelines and the ones used by competitors (or by yourself) to analyze content and SEO.

None of this is WordPress’s fault. It was built to be flexible, and flexibility has a cost. The problem shows up when all you want is to publish text and images — and the system that’s supposed to help you do that demands more maintenance than the content itself.

What a static site solves (and what it doesn’t)#

A static site is the opposite of that machinery. Instead of assembling each page at the moment of the visit, every page is generated ahead of time — plain HTML files sitting on the server, ready to be served. No PHP running, no database, no admin login exposed to the internet. The server only needs to do one thing: deliver files. Any server does that fast, and the result is a site that loads in milliseconds with no attack surface to exploit.

Security improves dramatically. No database means no SQL injection. No web-accessible admin panel means no brute force on /wp-login.php. No PHP means no remote code execution. The site is a collection of text files — about as vulnerable as a folder of documents on a file server.

But it’s worth being honest about the limits. A static site isn’t a drop-in WordPress replacement for everyone. Features that depend on server-side processing — native comments (though self-hosted solutions exist), database-driven search, e-commerce with shopping carts, members-only areas with login — don’t exist in plain HTML. They can be added with external services (and we’ll cover that later), but complexity goes up. If your site relies heavily on real-time interaction or dynamic features, WordPress still makes sense. For blogs, news sites, portfolios, documentation, and institutional pages, a static generator delivers more with less headache.

The pieces of the puzzle#

For a static site to work like a real blog — with a visual editor, automatic deploys, and no dependency on your local machine — you need four pieces working together. None of them is complicated on its own, but understanding what each one does before you start saves confusion later.

graph TD
    A["Pages CMS
Author writes in browser"] -->|commit via API| C["GitHub
Markdown + theme + config + images"] B["Developer
git push (theme, config)"] -->|git push| C C -->|webhook| D["Cloudflare
hugo build → global CDN"] D -->|static HTML| E["yoursite.com"]

Hugo — the site generator#

Hugo is the program that turns your text files into a complete HTML website. You write in Markdown (a plain text format with lightweight markup for headings, links, bold, and the like), pick a visual theme, and Hugo compiles everything into ready-to-publish HTML pages. It generates the entire site at once — every post, every category page, the RSS feed, the sitemap — and places the output in a folder called public/.

Hugo is written in Go and is absurdly fast. A site with 500 posts compiles in under a second. That matters because every time someone publishes a new post, the entire site needs to be rebuilt, and nobody wants to wait minutes for that.

Other static generators exist — Jekyll, Eleventy, Astro — but Hugo has the right combination of speed, maturity, and theme ecosystem for what we’re building here.

Pages CMS — the editor for humans#

A static generator on its own requires you to edit text files in a Git repository. For a developer, that’s natural. For anyone else, it’s a nightmare. Pages CMS solves this by placing a friendly web interface in front of the repository.

In practice, the author opens Pages CMS in the browser, sees a list of posts, clicks “new post,” fills in the fields (title, date, categories, content), and saves. Behind the scenes, Pages CMS takes all of that and creates a Markdown file in the GitHub repository with the front matter (the post’s metadata) and the formatted body text. The author doesn’t need to know that Git exists.

Pages CMS is open source, free, and runs on their own service — you don’t install anything on your server. The entire configuration is a single YAML file in the repository that defines which fields each content type has.

Cloudflare — the invisible hosting#

Cloudflare comes in as the infrastructure that builds and hosts the site. When someone publishes a post through Pages CMS (or when you push directly to the repository), Cloudflare detects the change, clones the repository, runs Hugo to generate the HTML, and distributes the result across its global network of over 300 data centers. A visitor in São Paulo gets the site from a server in São Paulo; a visitor in Lisbon gets it from a server in Lisbon.

Cloudflare’s free plan offers unlimited bandwidth and 500 builds per month — enough to publish more than 15 posts a day without paying a cent. The site is fast by default, HTTPS is automatic, and if an article goes viral, Cloudflare absorbs the traffic without flinching. (For a detailed look at the real limits of each service in this stack and what to do when you hit them, see The Dark Side of Free.)

GitHub — the vault where everything lives#

GitHub is the central repository. All of the site’s content — the Markdown files, the images, the Hugo configuration, the theme files — lives in a Git repository. That means every change is versioned: if someone edits a post and the result looks wrong, you just roll back to the previous version. If someone deletes something by accident, the full history is preserved.

GitHub is also the connection point between the other pieces. Pages CMS communicates with GitHub to read and write content. Cloudflare connects to GitHub to detect changes and trigger the build. Your local machine, if you prefer editing locally, also talks to GitHub via Git. Everything converges to the same place, and everything is recorded.

How the pieces fit together#

Four independent tools are useless if they don’t talk to each other. The good news is that the integration between them is nearly automatic — once configured, the flow works without human intervention.

The flow: from editor to published site#

Everything starts in Pages CMS. The author opens the browser, accesses the dashboard, and writes a post. Could be on the office desktop, the laptop on the couch, or a phone on the bus — doesn’t matter, it’s a web page. They fill in the title, pick a category, write the text, upload an image, and click save.

Pages CMS takes that data and makes a commit to the GitHub repository. In practice, it creates a Markdown file with the post’s metadata in the header (title, date, categories) and the content right below. If the author uploaded an image, it goes along to the repository’s media folder. All of this happens through GitHub’s API — Pages CMS never stores anything, it only reads from and writes to the repository.

GitHub receives the commit and fires a webhook to Cloudflare. Cloudflare wakes up, clones the latest version of the repository, and runs Hugo. Hugo reads all the Markdown files, applies the visual theme, and generates the entire site — each post becomes an HTML page, the listings are recreated, the RSS feed is updated, the sitemap is rebuilt. The result is a folder with dozens (or hundreds) of static HTML files, ready to serve.

Cloudflare distributes those files across its network of data centers spread around the world. When someone visits the site, they receive the HTML from the nearest server. No queue, no processing, no database — just a file being delivered.

From the click on “save” to the post being live, the entire process takes between one and two minutes. Most of that time is Cloudflare provisioning the build environment. Hugo itself compiles the site in under a second.

What happens when someone clicks “Save”#

Worth detailing the sequence because it shows where each piece acts and where things can go wrong:

The author clicks “Save” in Pages CMS. The CMS sends a request to GitHub’s API with the Markdown file contents and, if there’s an image, the image binary. GitHub receives it and records it as a normal commit — with author, date, and message. That commit shows up in the repository’s history like any other, and can be reverted if needed.

GitHub notifies Cloudflare that a change was made to the main branch. Cloudflare starts the build process: it provisions a temporary container, clones the repository, installs the Hugo version you configured, and runs the build command. If the build fails — a syntax error in a template, a corrupted file — Cloudflare keeps the previous version of the site live and logs the error. The site never goes down because of a broken build.

If the build passes, Cloudflare compares the generated files with the ones already distributed across the network. Only the files that changed get updated — if you published a new post, only the affected pages (the post, the listing, the RSS feed) are redistributed. The rest of the site stays as it was, served from cache.

The end result is that the author writes in a friendly web editor and, two minutes later, the content is available to the entire world on a site that loads in milliseconds. No server to maintain, no database to optimize, no fear of attacks. If the author publishes something wrong, just edit or revert — the full history is on GitHub.

Getting started#

From here on, every step has real commands you’ll run in the terminal. If something goes wrong, stop and read the error message before moving on — most problems at this stage are typos or wrong paths.

Prerequisites#

You’ll need four things installed on your machine before you begin:

Git — the version control system. If you’re on a Mac, you probably already have it (type git --version in the terminal to check). On Linux, sudo apt install git does the job. On Windows, download it from git-scm.com.

Hugo — the site generator. On Mac with Homebrew: brew install hugo. On Linux, download the .deb from the releases page on Hugo’s GitHub. On Windows, Chocolatey or Scoop install it with a single command. Confirm with hugo version — you need version 0.128 or higher, and the extended build (which is the default when installing through package managers).

A GitHub account — free, at github.com. If you work in development, you already have one. If not, create one now.

A Cloudflare account — free, at dash.cloudflare.com. You’ll be using the free plan, which is more than enough for what we’re doing.

Nothing else to install. Pages CMS runs in the browser and requires no local setup.

Creating the repository on GitHub#

Go to github.com/new and create a new repository:

  • Repository name: pick something short with no spaces, like my-blog or the slug of your domain.
  • Visibility: Private (your code doesn’t need to be public).
  • Initialize with: check “Add a README file.”

After creating it, clone the repository to your machine. Open the terminal and type:

cd ~/projects
git clone git@github.com:YOUR-USERNAME/my-blog.git
cd my-blog

Replace YOUR-USERNAME with your GitHub username and my-blog with the name you chose. If the ~/projects folder doesn’t exist, create it with mkdir ~/projects first.

Installing Hugo and choosing a theme#

With the terminal open inside your repository directory, create the Hugo site structure:

hugo new site . --force

The --force is necessary because the directory already exists (it has the GitHub README). Hugo will create several folders — content/, layouts/, static/, themes/ — and a hugo.toml file with minimal configuration.

Now add the theme. In our case we’re using Terminal, a theme with a command-line aesthetic that fits a tech blog:

git submodule add https://github.com/panr/hugo-theme-terminal.git themes/terminal

The git submodule add command downloads the theme and registers it as a dependency of your repository. This matters: when Cloudflare clones the repo to run the build, it will know it needs to download the theme as well.

Choosing a theme is a decision you can change later, but it’s easier to get right the first time. Hugo’s theme directory at themes.gohugo.io has hundreds of options. For a news or magazine blog, Mainroad is a solid pick. For technical documentation, Docsy. For something minimalist, PaperMod. Browse around, check the demos, and pick one that looks like what you want — the installation is always the same git submodule add command with the theme’s URL.

Configuring the site#

Delete the auto-generated hugo.toml and create a new one with your site’s configuration. Each theme has its own options, but the basic structure is similar. Here’s a working example for the Terminal theme:

baseURL = "https://your-domain.com/"
title = "Your blog name"
languageCode = "en"
defaultContentLanguage = "en"
theme = "terminal"
paginate = 5

[params]
  contentTypeName = "posts"
  themeColor = "orange"
  showMenuItems = 3
  fullWidthTheme = false
  centerTheme = true
  subtitle = "Your tagline here"

[params.logo]
  logoText = "Your blog name"

[languages]
  [languages.en]
    languageName = "English"
    title = "Your blog name"

    [[languages.en.menu.main]]
      name = "Home"
      identifier = "home"
      url = "/"
      weight = 1

    [[languages.en.menu.main]]
      name = "Posts"
      identifier = "posts"
      url = "/posts"
      weight = 2

    [[languages.en.menu.main]]
      name = "About"
      identifier = "about"
      url = "/about"
      weight = 3

[markup]
  [markup.tableOfContents]
    startLevel = 2
    endLevel = 3
    ordered = false

Replace the obvious values — baseURL, title, subtitle, logoText — with your own. The baseURL will change when you set up the final domain, but for now you can leave anything in there; what matters is that it’s not empty.

The showMenuItems parameter controls how many items appear in the main menu before the rest are tucked into a submenu. If you have three menu items, set it to 3. If you add a fourth later, update it to 4.

First test post#

Create the folder structure for posts and the About page:

mkdir -p content/posts

Create the file content/posts/first-post.md with the following content:

---
title: "My first post"
date: 2026-03-26T10:00:00
description: "A test post to verify everything works."
tags:
  - "test"
slug: why-leave-wordpress-and-what-to-build-instead-with-hugo-pages-cms-and-cloudflare
draft: false
toc: false
---

If you're reading this, Hugo is working.

## A test heading

Here's a normal paragraph with **bold** and *italic*.

### A sub-heading

And a list:

- Item one
- Item two
- Item three

Done. If this rendered correctly, the theme is
configured and we can move on.

Also create the page content/about.md:

---
title: "About"
slug: why-leave-wordpress-and-what-to-build-instead-with-hugo-pages-cms-and-cloudflare
draft: false
---

Write a short description about yourself or your blog here.
This page is accessible from the main menu.

Testing locally before publishing#

Run Hugo’s development server:

hugo server -D

The -D flag includes posts marked as draft. Open http://localhost:1313 in the browser and you should see the site with the theme applied, the menu working, and the test post in the listing.

Browse around. Click the post to make sure it opens. Check that the menu shows the items you configured. If something isn’t right, edit hugo.toml and save — Hugo reloads automatically and the browser refreshes on its own.

When you’re satisfied, it’s time to push to GitHub:

git add .
git commit -m "Initial setup: Hugo + theme + first post"
git push

From this point on, your site exists as code on GitHub. In the next section, we’ll put it live.

Going live with Cloudflare#

So far, the site only exists on your machine and on GitHub. Nobody else can see it. Cloudflare is what will turn the repository into a public site, accessible from any browser in the world.

Connecting the repository#

Go to dash.cloudflare.com and, in the sidebar, click Workers & Pages. Click Create application and choose the option to import a repository from GitHub. If this is your first time connecting Cloudflare to GitHub, it will ask you to install the “Cloudflare Workers and Pages” app on your account. During installation, GitHub asks which repositories Cloudflare should have access to — you can grant access to all of them or select only your blog’s repository. I recommend selecting only what you need; you can add others later.

One detail that trips up a lot of people: if you create a new repository after you’ve already done this setup, Cloudflare won’t see it automatically. You need to go back to the GitHub App settings (at github.com/settings/installations), click “Configure” next to “Cloudflare Workers and Pages,” and add the new repository to the permissions list.

With the repository visible, select it and proceed to the build configuration screen.

Configuring the build#

The configuration screen asks for a few pieces of information. Cloudflare’s interface changes fairly often, so the exact field names may vary, but the concept is always the same:

The Build command is the command Cloudflare will run to generate the site. Use:

hugo --minify

The --minify flag is optional but reduces the size of the generated HTML by stripping unnecessary whitespace and line breaks. It doesn’t affect the site’s appearance, just shrinks the file sizes.

You’ll need a wrangler.toml file at the root of your repository so Cloudflare knows where to find the build output. Create the file with this content:

name = "your-project-name"
compatibility_date = "2026-03-22"

[assets]
directory = "./public"

The [assets] section with directory = "./public" tells Cloudflare that the content to be published lives in the public/ folder — which is exactly where Hugo places the generated HTML.

In the environment variables settings (usually hidden behind an “Advanced settings” button or similar), add a variable called HUGO_VERSION with the value of the version you installed on your machine — for example, 0.158.0. Without this variable, Cloudflare will use an old Hugo version and the build may fail silently or generate a site that looks different from what you saw locally.

Commit and push the wrangler.toml:

git add wrangler.toml
git commit -m "Add wrangler.toml for Cloudflare"
git push

The first deploy (and the errors you’ll run into)#

Click Deploy and watch the log. Cloudflare will clone the repository, install Hugo, run the build, and publish the result. The first deploy takes between one and two minutes.

If everything goes well, the log ends with a success message and a URL in the format your-project-name.your-subdomain.workers.dev. Open it in the browser and check — the site should be identical to what you saw at localhost:1313.

But it’s worth being prepared for what usually goes wrong on the first attempt, because almost nobody gets it right the first time:

“Unable to locate config file” means Cloudflare is running Hugo in the wrong directory. Check that the “Path” field in the build configuration is set to / (the repository root) and not pointing to some subdirectory.

“Error building site: template failed” usually indicates a version mismatch between Hugo and the theme. Older themes use functions that Hugo removed in recent versions. Confirm that the HUGO_VERSION variable is set and that the value matches the version that works on your machine.

“Authentication error” during deploy may indicate that the automatically generated API token doesn’t have the necessary permissions. If this happens, try recreating the project from scratch — sometimes Cloudflare’s interface stumbles on the first setup and works perfectly on the second.

The key thing to know is that Hugo’s build and the Cloudflare deploy are separate steps. If the log shows “Build command completed” followed by an error in the deploy, the problem isn’t with your site — it’s in the communication between Cloudflare and your account. If the error appears during the build, before “Build command completed,” the problem is in the Hugo configuration or the theme.

Once the first deploy works, all subsequent ones are automatic. Every push to the repository — whether from your terminal or from Pages CMS — triggers a new build and deploy without you touching anything. The entire cycle, from commit to updated site, takes between one and two minutes.

With the site live, update the baseURL in hugo.toml to the URL that Cloudflare generated, commit, and push. This detail is easy to forget, but Hugo uses baseURL to generate internal links, the sitemap, and the RSS feed — if it’s wrong, those features will point to the wrong place.

Connecting Pages CMS#

The site is live and automatic deploys work. What’s missing is the piece that makes all of this usable by someone who doesn’t know (and doesn’t want to know) what Git is: the web editor.

The .pages.yml file — the map of your content#

Pages CMS doesn’t guess your site’s structure. You need to tell it where the posts live, which fields each post has, and where images are stored. All of that goes in a single file called .pages.yml, at the root of the repository.

A working example for a Hugo blog:

media:
  input: static/img
  output: /img
content:
  - name: posts
    label: Posts
    type: collection
    path: content/posts
    view:
      fields: [title, date]
    fields:
      - name: title
        label: Title
        type: string
      - name: date
        label: Date
        type: date
      - name: description
        label: Summary
        type: string
      - name: cover
        label: Cover image
        type: image
      - name: tags
        label: Tags
        type: string
        list: true
      - name: toc
        label: Table of contents
        type: boolean
        default: true
      - name: draft
        label: Draft
        type: boolean
        default: false
      - name: body
        label: Content
        type: code
        options:
          language: markdown

Each block inside fields corresponds to a field the author will see in the editor. The type defines the control — string is a simple text input, date shows a date picker, image allows uploads, boolean is an on/off toggle, and code with the language: markdown option provides a text editor with Markdown syntax highlighting. The labels can be in any language — the Portuguese version of this blog uses localized labels like “Título” and “Resumo” and they work just the same.

The media section deserves attention. The input is the path inside the repository where images are saved — static/img. The output is the path as Hugo will serve those files on the final site — /img. This distinction exists because Hugo serves everything inside static/ from the site root, stripping the static prefix from the URL. If you set media: static/img without separating input and output, images will work in the editor but return 404 on the published site.

Commit and push the file:

git add .pages.yml
git commit -m "Add Pages CMS configuration"
git push

Accessing the editor for the first time#

Open app.pagescms.org and log in with your GitHub account. Pages CMS will ask for permission to access your repositories — just like Cloudflare, it works as a GitHub App, and you can restrict access to specific repositories.

After authorizing, the main screen shows the list of projects. Select your blog’s repository and Pages CMS will read the .pages.yml to build the interface. If everything is set up correctly, you’ll see “Posts” in the sidebar and, when you click it, the list of existing posts in the repository.

If the repository doesn’t show up in the list, the problem is the same as with Cloudflare: the Pages CMS GitHub App doesn’t have access to the repository. Go to github.com/settings/installations, find the Pages CMS app, and add the repository.

Creating and publishing a post from the browser#

Click “Add an entry” in the upper right corner. The editor will show the fields you defined in .pages.yml — title, date, summary, cover image, tags, and the Markdown content field.

Fill in the fields and write the post in the Markdown editor. The field uses CodeMirror with syntax highlighting, so headings, links, bold, and code appear colored as you type. It’s not a WYSIWYG editor with live visual preview, but for anyone comfortable with Markdown the experience is smooth.

When you’re done, click “Save.” Pages CMS will create the Markdown file in the repository with the filled-in front matter and the post content. That commit triggers the webhook to Cloudflare, which runs the Hugo build and publishes the updated version of the site. In one to two minutes, the post is live.

A note about deleting posts: Pages CMS has a known bug in the delete function — the post appears to be removed in the dashboard, but the file remains in the repository, and the post keeps showing up on the site. Until this is fixed, the safe way to “unpublish” a post is to edit it and toggle the Draft field on. Hugo ignores posts marked as draft in the production build, so the post disappears from the site without needing to delete the file. If you truly need to remove a post from the repository, do it from the terminal with git rm and push.

Setting up the cover image#

The cover field in .pages.yml lets the author upload an image to be used as the post’s featured image — in the listing, at the top of the article, and in social media sharing cards. The exact behavior depends on the theme: Terminal uses the cover field in front matter, Mainroad uses thumbnail, and other themes may have different names. Check the documentation of the theme you chose for the correct field name.

When clicking the image field in the editor, Pages CMS lets you select an existing image from the media folder or upload a new one. The image is saved to the repository at the path configured in media.input, and the path written to the post’s front matter follows the pattern defined in media.output.

The image path detail#

This is the most common error in the integration between Pages CMS and Hugo, and it’s worth reinforcing because it’s a silent one — everything appears to work in the editor, but images show up broken on the site.

Hugo serves files from the static/ folder directly at the site root. A file at static/img/photo.jpg is accessible at yoursite.com/img/photo.jpg, not at yoursite.com/static/img/photo.jpg. If Pages CMS writes the full repository path in the front matter (/static/img/photo.jpg), the browser will request a file that doesn’t exist at that path and show a broken image.

The solution is the separation between input and output in the media configuration of .pages.yml that we showed above. The input: static/img tells Pages CMS where to save the file in the repository. The output: /img tells it which path to write in the post’s front matter. When Hugo generates the site, the file is in static/img/ and the reference in the HTML points to /img/ — and everything lines up.

Custom domain#

The site works on the .workers.dev subdomain that Cloudflare generated, but nobody is going to take a blog seriously with a URL that looks like a test project. Setting up a custom domain is the finishing touch that turns the setup into a real site.

Pointing the DNS#

If your domain is already on Cloudflare (as nameserver or with active proxy), the process is straightforward. In the Cloudflare dashboard, go to DNSRecords and add a CNAME record:

  • Type: CNAME
  • Name: the subdomain you want (for example, blog for blog.yourdomain.com, or @ to use the root domain)
  • Target: the URL that Cloudflare generated for your project, without the https:// — something like your-project-name.your-subdomain.workers.dev
  • Proxy status: Proxied (orange cloud active)

Then go to Workers & Pages, open your site’s project, and under Settings look for the Custom Domains section. Add the domain — for example, blog.yourdomain.com. Cloudflare will verify that the CNAME exists and associate the domain with the project.

If your domain is registered elsewhere (GoDaddy, Namecheap, Porkbun), you have two options. The simplest is to transfer the nameservers to Cloudflare — the free plan includes DNS hosting, and propagation takes anywhere from minutes to a few hours. The alternative is to create the CNAME in your current registrar’s panel pointing to the .workers.dev address, but in that case you lose Cloudflare’s proxy and some cache optimizations.

DNS propagation can take anywhere from a few minutes to 24 hours, depending on the configured TTL and the provider. In practice, with Cloudflare as the nameserver, the change usually reflects in under five minutes.

Automatic HTTPS#

Nothing to do here. Cloudflare automatically generates and renews an SSL certificate for your custom domain. As soon as DNS propagates and Cloudflare recognizes the domain, the site starts responding over HTTPS with no additional configuration, no certificate to install, and no manual renewal.

If someone accesses the site over HTTP, Cloudflare automatically redirects to HTTPS. This is enabled by default — but if for some reason it isn’t, go to SSL/TLSEdge Certificates and turn on “Always Use HTTPS.”

After confirming that the domain is working, update the baseURL in hugo.toml to the final address, commit, and push. This is the last time you need to touch that field.

What about the things WordPress did out of the box?#

Anyone coming from WordPress is used to solving everything with plugins. Search, comments, contact forms, scheduled posts — everything is one click away in the dashboard. On a static site, these features don’t come for free, but they’re not impossible either. Some are surprisingly easy; others require compromises.

Search on a static site — how it works without a database#

The first reaction from anyone discovering static sites is usually: “but how does search work without a database?” The answer is that search runs entirely in the visitor’s browser.

During the build, Hugo can generate a JSON index file containing the titles, summaries, and (optionally) the full content of every post. When the visitor opens the search page and types something, a JavaScript file loads that index and filters the results right there, with no server request at all.

The most common solution is Fuse.js — a lightweight fuzzy search library that works well for small to mid-sized blogs. The visitor types, results appear instantly as they keystroke. Several Hugo themes already ship with Fuse.js search built in; you just need to enable it in the configuration.

The thing to watch is the index size. If the blog has 50 posts, the JSON weighs a few kilobytes and loads imperceptibly. With 500 posts indexing full content, the file can grow past 2 or 3 MB — the visitor needs to download all of that before search works. The solution for large sites is Pagefind, a tool that generates a fragmented binary index and only downloads the relevant chunks for each query. It integrates with Hugo without trouble and scales to thousands of pages without the visitor noticing any slowdown.

Comments without a backend — Giscus, Utterances, and alternatives#

Comments are the feature you miss most when leaving WordPress, and at the same time the one that causes the most headaches on any platform. Spam, moderation, GDPR, performance — WordPress’s native comment system handles the basics, but any serious installation ends up resorting to plugins like Akismet or Disqus anyway.

On a static site, comments need an external service. The most popular free options:

Giscus uses GitHub’s Discussions system as its backend. Each blog post becomes a discussion in the repository, and comments are threads within it. The visitor needs a GitHub account to comment, which is a barrier for non-technical audiences but a natural spam filter for tech blogs. Integration is a JavaScript snippet in the theme template.

Utterances works similarly but uses GitHub Issues instead of Discussions. Each post generates an issue, and comments are replies on it. The experience is nearly identical to Giscus, with the same limitation of requiring a GitHub account.

Disqus is the most universal — it accepts login through various social networks and email — but it injects heavy scripts, tracks visitors, and shows ads on the free plan. If the blog’s audience isn’t technical and needs a familiar commenting experience, Disqus works, but the cost is performance and privacy.

For tech blogs, Giscus is the best choice. For blogs aimed at a general audience, the best option may be to simply not have comments and direct the conversation to social media — which is what most major news sites do today. There is also a middle ground: if you already run a server and want full control over your visitors’ data, a self-hosted comment system like Isso provides lightweight comments with no tracking and no third-party dependencies.

Hugo has a native related posts system that works without any plugin. It analyzes tags, categories, publication date, and front matter keywords to calculate a relevance score between posts and display a list of suggestions at the end of each article.

The configuration goes in hugo.toml:

[related]
  includeNewer = true
  threshold = 80
  toLower = true

  [[related.indices]]
    name = "tags"
    weight = 100

  [[related.indices]]
    name = "categories"
    weight = 80

  [[related.indices]]
    name = "date"
    weight = 10

The threshold sets the minimum score for a post to show up as related — the higher the value, the more restrictive. The weights on each index control what matters most in the comparison: in this example, shared tags are worth more than categories, and publication date has minimal influence.

The display depends on the theme. Some themes already include a related posts block in the post layout; others require you to add the snippet to the template. In the worst case, it’s a few lines of Go template to insert the list — Hugo’s documentation covers this in detail.

Scheduled posts — publishing in the future without a cron job#

Hugo has native support for future dates. If a post’s front matter has a date later than the moment of the build, Hugo simply doesn’t include the post in the generated site. The post exists in the repository but is invisible to visitors.

The problem is that someone needs to trigger the build after the date arrives. In WordPress, the internal cron handles this automatically. On a static site, the build only happens when there’s a push to the repository or when someone triggers it manually.

The simplest solution is a GitHub Action on a schedule. An Action that runs once a day (or every hour, depending on your needs) makes an empty push to the repository, which triggers the build on Cloudflare. If there are posts whose date has already passed, they appear on the site; if not, the build finishes without changing anything.

name: Publish scheduled posts
on:
  schedule:
    - cron: '0 */6 * * *'
jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          git config user.name "github-actions"
          git config user.email "actions@github.com"
          git commit --allow-empty -m "Trigger build for scheduled posts"
          git push

This workflow runs every six hours and makes an empty commit — enough to trigger the build without changing any content. The granularity is adjustable; if you need posts to go live with hourly precision, change the cron to run every hour. For most blogs, once a day is enough.

Ads — where and how to insert them in layouts and posts#

Monetization with ads works normally on a static site — the HTML generated by Hugo is identical to what any dynamic CMS would produce, and ad networks (Google AdSense, Ezoic, Mediavine) can’t tell the difference. What changes is where you place the code.

In WordPress, plugins like Ad Inserter let you position ads without touching code. In a Hugo site, ads go directly in the theme’s templates. There are two natural places:

For ads in the layout (sidebar, header, footer, between posts in the listing), you edit the theme’s partials. Most well-structured themes have files like layouts/partials/header.html, layouts/partials/footer.html, and layouts/partials/sidebar.html. The ad code — usually a JavaScript snippet provided by the ad network — goes straight into these files, in the desired position.

For ads inside post content (for example, after the third paragraph), the cleanest approach is to create a shortcode. Create the file layouts/shortcodes/ad.html with the ad code, and in the post’s Markdown insert {{< ad >}} wherever you want the ad to appear. This keeps content separate from monetization — if you switch ad networks, you change one file instead of editing hundreds of posts.

Contact form — options without a server#

A static site doesn’t process forms — there’s no backend to receive the data. But specialized services exist that solve this with a single line of configuration:

Formspree is the simplest. You create a normal HTML form on your site, point the action to the Formspree endpoint, and submissions arrive in your email. The free plan allows 50 submissions per month, which is more than enough for a blog.

Formspark and Basin work similarly, with free plans of varying capacity. Cloudflare Workers can also process forms, which keeps everything within the same ecosystem — but it requires writing a bit of JavaScript.

For most blogs, a simple form with Formspree does the job without any complication. Add a page content/contact.md with the form in plain HTML in the Markdown body — Hugo renders HTML inside Markdown without issues.

RSS — the feed that comes ready out of the box#

This is the shortest good news in the post: Hugo generates RSS automatically. Nothing to install, nothing to configure. The feed is available at /index.xml by default and includes all posts with title, date, summary, and link.

If you want to customize what appears in the feed — for example, including the full content instead of just the summary, or limiting the number of items — Hugo lets you override the RSS template. But for most blogs, the default works perfectly from the very first build.

Just add the link to the navigation menu or the site footer so readers can find it. RSS aggregators like Feedly, Inoreader, and NewsBlur recognize the format automatically.

What I learned along the way#

Advantages I didn’t expect#

The most obvious advantage — speed — I already expected. A static site served from a CDN loads fast, end of story. What surprised me were the side benefits that only show up after using the setup for a while.

Content versioning is the first one. In WordPress, if an editor overwrites a paragraph and saves, the previous version gets buried in a revision system that almost nobody knows how to use. With content in Git, every change is a commit with date, author, and diff. If someone publishes something wrong at two in the morning, reverting is a git revert — or, worst case, a click in GitHub’s history. This sounds like a technical detail until the day it saves your neck.

Operational cost was the second surprise. It’s not just that hosting is free — it’s the total absence of infrastructure maintenance. No server to update, no PHP to keep compatible, no MySQL to optimize, no cache plugin to configure. The site simply exists as a set of static files distributed around the world. The monthly infrastructure bill is zero. The monthly hours spent putting out fires is also zero.

The third was portability. The site’s entire content is a Git repository with Markdown files. If Cloudflare disappears tomorrow, I move the deploy to Netlify, Vercel, or an Nginx on my own server in under an hour. If Pages CMS shuts down, the files are still on GitHub — I just need another editor, or any text editor at all. No piece of the system is irreplaceable, and none of them holds my data hostage.

Limitations you need to know about#

The most concrete limitation is the absence of native dynamic features. Everything that depends on server-side processing — advanced search, comments, forms, restricted areas — needs an external service or a workaround. The solutions exist (and we covered the main ones in this post), but each one adds a dependency and a configuration point. In WordPress, it’s a plugin. Here, it’s an external service with its own account, its own documentation, and its own limits.

Pages CMS, while functional, is still a young project maintained essentially by one person. There is no rich-text editor — the content field is a code editor with Markdown syntax highlighting, which is fine for anyone who knows the syntax but unworkable for a writer who has never seen a ## in their life. The delete function has a bug that leaves the file in the repository even after confirming the deletion. These are problems you can work around, but they show that the tool isn’t ready to hand off to a non-technical client without supervision.

The build isn’t instant. Between clicking “save” and the post appearing on the site, one to two minutes go by. For a blog, that’s irrelevant. For a news site that needs to publish with breaking-news urgency, it can be an annoyance. Hugo compiles in under a second — the latency is entirely in Cloudflare provisioning the environment and distributing the files.

Editing the theme requires working with Go templates. Hugo’s template system is powerful, but the syntax isn’t intuitive for anyone coming from WordPress’s PHP — I wrote a separate post about the mental model shift from WordPress themes to Hugo themes that covers this in detail. Simple things like changing the position of an element on the page or adding a custom field to the post layout mean opening an .html file full of {{ }} and understanding the logic of partials, blocks, and contexts. The learning curve isn’t steep, but it exists, and it shows up precisely when you want to do something the theme didn’t anticipate.

Who this approach makes sense for (and who it doesn’t)#

This stack makes sense for personal and professional blogs, documentation sites, portfolios, institutional sites, and small news sites. It makes especially good sense when the author is technical or has access to someone technical who can handle the initial setup — because the configuration requires a terminal, Git, and reading documentation. Once configured, the day-to-day is simple enough for anyone who knows how to fill in a web form.

It doesn’t make sense for sites that depend on heavy real-time interactivity: e-commerce with shopping carts, web applications with user login, discussion forums, e-learning platforms with student progress tracking. These features need a backend, and trying to replicate them with external services glued onto a static site is swimming against the current.

It also doesn’t make sense — at least for now — when the authors are completely non-technical and nobody is available to sort out the inevitable stumbles. Pages CMS is friendly, but it’s not WordPress. There’s no dashboard with metrics, no visual preview before publishing, no trash bin that recovers accidentally deleted posts. Whoever operates the site needs a minimum level of comfort with digital tools and, ideally, access to someone who can open a terminal when things go wrong.

For everything else — for anyone who wants a fast, secure site that’s cheap to run and doesn’t require babysitting a server around the clock — this combination of Hugo, Pages CMS, and Cloudflare delivers more than enough with less hassle than any dynamic alternative I’ve ever managed.

If you want to understand where the real limits of this free stack are — and what to do when you hit them — I wrote a dedicated post on the subject. And when the number of posts grows and keeping tags and meta descriptions consistent becomes a problem, Hugin uses AI to classify and summarize Hugo posts automatically.