Since I started blogging 3 years ago, I’ve using the Hugo static site generator exclusively. My tech stack has been:
- Hugo
- themes: PaperMod -> Gokarna
- Firebase Hosting via Github Actions -> Cloudflare Pages
I’ve enjoyed the numerous themes the Hugo community shared. However, there’re features fundamentally missing in the platform that require me to do lots of tweaks. I have been spending way too much time in these efforts:
shopping for themes supporting these features
porting my site from theme to theme
different themes require different Hugo versions, you never know until you install the theme to try out
themes have different naming conventions in metadata and implementation details in partials/CSS. There’s never a smooth migration
rolling up my sleeves and building a lot of desired features myself with templates, Go shortcodes, Javascript and CSS.
Gradually this reached an inflection point in which I think the efforts do not translate to scalable skills for my long term career goal. I’d really like to focus on building apps and writing quality content, instead of reinventing the wheel. As the year-end approached, I wanted to do a complete refresh of my blog to take care the customization hurdles. After searching high and low and viewing many good examples, I’ve jumped into the Quarto world.
I’ll outline the problems I have with Hugo blogs and how the migration resolves it.
I will focus on building Quarto websites in this post. Note that Quarto also supports creating presentations, dashboards from programmatic outputs, interactive features.
Quarto’s advantages over Hugo
Flexible Listings
The top win for me is the flexibility in Quarto’s listings. I can slice and dice all my files using file path, frontmatter fields, etc. to produce endless combinations of lists. It is much more versatile than tinkering with Hugo’s layout partials. And since the listing is done in yaml, it is more intuitive and easier to keep track than using Go in Hugo.
For example, I can have a projects.qmd file that lists all the files under /blog/ folder with type: project in their frontmatter. The beauty of this approach is that this listing is updated automatically whenever you add a new file with this metadata field. You can also control the layout of this listing with type: grid (displayed as a grid, table or list).
---
title: "Project Portfolio"
listing:
contents: blog
type: grid
include:
type: "project" # Only lists files that have 'type: project'
sort: "date desc"
---Alternatively, you can hardcode the contents to include specific files such as
contents:
- "blog/chatbot/index.qmd"
- "blog/recommender/index.qmd"In short, this is an extremely powerful way for organization and custom taxonomy, as everything can be filtered and displayed as a list.
Flexible Page Layout
As Quarto uses Bootstrap underneath, it is super easy to design multi-column content inline, unlike resorting to coding shortcodes and custom CSS yourself in Hugo. For example, formatting a side-by-side image with text can be done using CSS grids like this in markdown:
::: {.grid}
::: {.g-col-4}

:::
::: {.g-col-8}
Not easy to give up, you switch to eBird. Again you click Explore and flip the search from hotspots to species.
:::
:::which renders to 
Easy and fast!
Programmer’s delight
A strong proposition of Quarto, especially for coders, is the integration with Jupyter notebook so you can share BOTH code and output together in markdown, PDF or even a presentation. When you render your project containing a notebook, Quarto will run the respective engine (Python, R, Julia and Observable JS) to execute the code and render the output in your desired format. Not only that, but you can also create executable code blocks right inside a markdown page. This makes it much easier to walk through a coding project and is something I will try soon.
I’ve implemented code copy and title in the code block for my Hugo project, so when I see that it is natively supported in Quarto, I am really happy.
In fact, you can integrate Quarto with Hugo so you can reap the benefit of Quarto’s computational output publishing, while keeping your site in Hugo.
Bulk configuration of files
Quarto makes it very handy to apply settings to all files in a folder by using a _metadata.yml. For example, if you have multiple folders to hold projects, blog, talks and notes, and you only want to enable Giscus comments in your blog posts, you can create a _metadata.yml in your blog folder. Add the following content to it:
comments:
giscus:
repo: myrepo/Comment
reactions-enabled: true
loading: lazyThis will enable Giscus on all public .md and .qmd files in all nested folders under blog.
The idea is that metadata settings can be specified
- per page : in its own frontmatter
- at the
_metadatalevel : for all files under the folders where_metadata.ymllives - at the project level : by setting it up in
_quarto.yml, to be applied to every file in the project
In rendering, metadata defined within _quarto.yml, _metadata.yml, and document-level YAML options are merged together. Document level options take priority, followed by directory options and finally project-level options.
Website and HTML tools
I am also very happy with the native support of client-side search, tabsets, external link formatting, lightbox without installing modules or shortcodes.
VS Code integration
As I use VS Code for both coding and blogging, the Quarto extension makes migration as smooth as I hope. I can create new projects/docs, run code cells, preview and render the project all within the IDE. Very seamless!
My Quarto quips
It will not be fair to claim that Quarto trumps Hugo in every area. So what are my complains?
Slow rendering
Rendering speed of Pandoc (the underlying engine of Quarto) is one big disappointment if you are used to Hugo’s lightning fast build speed. Be prepared to wait a long time even after you set
_quarto.yml_
execute:
freeze: auto # This ensures only changed files are re-renderedIf you change the title of a file, Quarto detects that the source file has changed. It will unfreeze that specific post, re-run any code and rendering, and update the site’s metadata. Because the metadata changed, Quarto will also re-render the listing page (e.g., a blog index) to reflect the new title.
Loss of custom slugs
One flexibility I really miss from Hugo is the versatile slug metadata field for creating SEO-optimized URLs. With slug and a handy local folder organization scheme, I can store posts for different subjects in separate folders (with short and easy to read names like /blog/travel/kyoto/index.md), and have them all render nicely as mysite.com/blog/explore-traditional-tea-rooms-kyoto. In Quarto, URLs are straightly built from paths (so my post will becomemysite.com/blog/travel/kyoto/), forcing me to rethink my local organization strategy, as well as creating a lot of aliases and redirects.
Loss of prebuilt taxonomy pages
In Hugo, all tags, categories and series configured in the frontmatter of your posts have their own taxonomy pages. You don’t need any manual labor to generate them. Not so in Quarto. First, tag is called category in Quarto, and they are more like a dynamic filtered page. See an example of my Japan category page. I got this by
- Creating a
/blog/index.qmdlisting page to pull all content under the/blogfolder - Enabling
categories: cloudto generate the tag cloud - Clicking on any tag in the cloud will dynamically filter the items in this listing to generate all posts tagged as such.
---
title: "Blog"
format: html
toc: false
listing:
contents: . # Point this to your main blog directory
categories: cloud # This creates the "Tag Cloud"
fields: [title, date, categories, image, reading-time]
sort: "date desc"
---Similarly, given the flexbility in listing pages, you are on your own in regenerating a fixed page for each series. For example, I use the following listing page to consolidate all my posts with series: "Local LLM inference in OCI Ampere A1" in their frontmatters.
---
title: "Local LLM inference in OCI Ampere A1"
format: html
toc: false
listing:
contents: "../../blog/tech/*/index.*md"
fields: [title, date, reading-time, categories]
include:
series: "Local LLM inference in OCI Ampere A1"
sort: date desc
---Loss of leaf bundles
I used Hugo’s Leaf Bundle extensively to store drafts and resource files in the same folder as my index.md post file. In contrast, Quarto will render all valid Quarto input files (.md,.qmd,.ipynb, etc.) in that folder and its subdirectories, not just index.*md. To avoid building and accidentally publishing the supporting resource files, I have to modify all of them by prefixing their filenames with _ (underscore). Alternatively, if your resource files have specific naming conventions, you can control exactly which files Quarto won’t render by explicitly listing the excluded files in _quarto.yml like the following:
project:
render:
- "*.qmd" # Include all .qmd files
- "!ignored.qmd" # Exclude this specific file
- "!ignored-dir/" # Exclude all files in this directoryYou can also use wildcards and the ! prefix to ignore specific files:
Loss of Cloudflare Pages auto build
When I first started my Hugo blog, I used Firebase as my hosting provider. Since there is no continuous delivery built-in, I used Github Actions to build and deploy. Later, I moved to Cloudflare Pages which automatically picks up a new push from Github repo and build/deploy it.
Unfortunately, Cloudflare Pages doesn’t support building and deploying Quarto by default. Instead of doing a local render (which is slow) and push to Github, we can return to our trusty old friend Github Actions. The workflow should become:
- You Push: Only Markdown/Code changes go to GitHub.
- GitHub Action Starts: It installs Quarto, renders your site, and creates the
_sitefolder. - Wrangler Uploads: The Action sends only the
_sitefolder to Cloudflare. - Cloudflare Deploys: Cloudflare receives the files and puts them online instantly without running any build commands of its own.
Here’s how to setup CD for Quarto+Github+Cloudflare Pages:
1. Setup tokens
First, we need to provide GitHub with permission to talk to Cloudflare.
Get Cloudflare Account ID:
The Account ID is not your username or your email address. It is a unique, 32-character string (hexadecimal) that identifies your account in the Cloudflare system. The
wrangler-actionin your GitHub Action needs this ID so it knows exactly which account it’s trying to upload your site to. The quickest way to find it is to look at the browser URL while you are logged in to the Cloudflare dashboard.- Log in to your Cloudflare dashboard.
- Click on any project or your account home.
- Look at the address bar. The URL will look like this:
https://dash.cloudflare.com/be678xxxxxxxxxxxxxxxxxxxxxxx/pages/... - The string between
dash.cloudflare.com/and the next slash is your Account ID.
Create an API Token:
- Go to Profile (upper right corner) > API Tokens > Create Token.
- Select Create Custom Token (Get started).
- Name it
Quarto-Site-Deploy. - Add these two rows under Permissions:
- Account | Cloudflare Pages | Edit
- User | Memberships | Read
- Under Account Resources, select Include | [Select your account].
- Click Continue to Summary and then Create Token.
Add Token to GitHub:
- In your GitHub Repo, go to Settings > Secrets and variables (left nav bar) > Actions.
- Click New repository secret.
- Create
CLOUDFLARE_API_TOKENand paste the code from your Quarto-Site-Deploy token. - Create
CLOUDFLARE_ACCOUNT_IDand paste your Cloudflare ID.
2. Define Github workflow
Create a workflow file .github/workflows/publish.yml in your project root. We will use the official Quarto Actions to install Quarto and render our project.
.github/workflows/publish.yml
name: Render and Deploy Quarto
on:
push:
branches: [ main ] # Ensures it runs every time you push to main
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
- name: Render Quarto Project
uses: quarto-dev/quarto-actions/render@v2
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
# These must match the names in GitHub Settings > Secrets > Actions
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# Use your actual Cloudflare project name below
command: pages deploy _site --project-name=myBlogPush the project to your Github repo.
3. Connect a Cloudflare Pages project to Github
- In the left navigation bar, click Build -> Workers & Pages -> Create Application
- Select “Looking to deploy Pages? Get started”
- Click Import an existing Git repository, then choose your Github account and repository > If your repository is not shown, configure repository access for the Cloudflare Pages app on GitHub.
- Choose the Project Name you set in the Workflow yaml file. Enter
_siteas Build output directory and leave everything as default
Now Cloudflare Pages will watch your GitHub repo. If you push a change, both Cloudflare and GitHub Actions will try to start a build at the same time. The Cloudflare one will fail because it doesn’t have Quarto installed. To fix this, you need to disable the automatic build. - Go to your Cloudflare Pages project -> Settings -> Build -> Branch Control to disable Automatic deployments.
4. Add /_site/ to .gitignore
When you run quarto preview, Quarto creates (or updates) the local _site folder so you can see the website in your browser. Now that GitHub Actions is building the site, we don’t need to track these generated HTML files in our repository history because:
- Repo bloat: It makes the repository much larger than it needs to be.
- Merge conflicts: If you and the GitHub Action are both writing to that folder, you’ll constantly run into git conflicts.
- Cleanliness: The repo should only contain source code (the
.qmdfiles, images, and configuration).
Add these lines to the .gitignore file in the root folder:
.gitignore
/.quarto/
/_site/
| Folder | What is it? | Why? |
|---|---|---|
.quarto/ |
Internal cache | Temporary files Quarto uses while working. |
_site/ |
Local preview | Generated files; the GitHub Action will make its own fresh copy. |
If you have already pushed the _site folder to GitHub in previous commits, simply adding it to .gitignore won’t remove it. You need to tell Git to stop tracking it by:
- Open your terminal in your project folder.
- Run:
git rm -r --cached _siteto remove them from Git’s tracking index and stop following changes to them. - Run:
git add ., thengit commit -m "Stop tracking _site folder" - Run:
git push
Once you commit and push, the _site folder will disappear from your online repository and will not sync to it anymore.