From Notion to Hugo: Building a Fully-Automated Blog Pipeline
How I automated publishing from Notion to Hugo with custom themes, metadata, image optimization, and CI/CD for zero-click deployment
For years, I treated Notion as my private vault of ideas β essays, notes, and half-finished thoughts piled up in pages only I could access.
But I always wanted a public blog: something fast, professional, and structured, where I could share my writing with others. Not just a single-page notebook, but a real publishing system with categories, tags, SEO, search, author profiles, images, and automation.
The challenge was clear: how do I bridge the gap between a private Notion workspace and a fully functional blog β without manually copying and pasting every post?
β‘ The First Breakthrough: Hugo
The first decision was to adopt Hugo, a static site generator known for its speed and flexibility.
Why Hugo?
- It can build thousands of pages in seconds.
- It works Markdown-first, which pairs perfectly with Notionβs export format.
- It supports themes, layouts, and shortcodes for maximum customization.
- It generates static HTML files, which means the blog loads instantly and is easy to host anywhere.
With Hugo as the core, I now had a direction:
Notion β Markdown β Hugo β Live Blog
But making this flow smooth required solving a number of challenges.
π οΈ The Challenges
At first glance, exporting from Notion to Hugo seemed simple. But the details made it complex:
- Broken Exports β Notion markdown often comes with broken image paths, missing frontmatter, and inconsistent formatting.
- File Structure β Hugo requires posts, images, and data files to live in well-structured folders.
- Metadata β Posts need titles, tags, categories, authors, and dates. Notion exports donβt provide these in Hugo-compatible format.
- Themes & Styling β Default Hugo themes didnβt fit my vision. I wanted to design my own.
- Performance β Optimizing images, scripts, and layouts for fast loading was critical.
- Deployment β I didnβt want to manually upload files every time. Continuous deployment was a must.
π Building the System
Instead of giving up on Notion exports, I decided to engineer a full pipeline that could process them and produce a production-ready Hugo site.
Hereβs what I built:
1. Processing Notion Files
I wrote scripts to process raw Notion exports:
- Convert
.mdfiles into Hugo-compatible Markdown. - Inject frontmatter with title, tags, categories, and author information.
- Move exported images into Hugoβs
static/images/folder and fix all references. - Normalize formatting (headings, code blocks, lists).
This turned messy Notion exports into clean, blog-ready Markdown files.
2. Theme Development
Rather than using an existing theme, I built a custom Hugo theme tailored for my blog:
- Responsive design optimized for reading.
- SEO tags (Open Graph, Twitter cards, sitemaps).
- Author profiles with avatars and social links.
- Support for tags, categories, and related posts.
- Newsletter and contact forms integrated directly into layouts.
By building the theme myself, I could ensure it aligned perfectly with the automation pipeline.
3. Metadata & Organization
Each post automatically receives structured metadata. Example frontmatter:
---
title: "Building a Notion-to-Hugo Blog"
date: 2025-09-01
author: "Akshat Jain"
tags: ["notion", "automation", "hugo", "static-site"]
categories: ["blog-automation"]
description: "How I automated publishing from Notion to Hugo with custom themes, metadata, and CI/CD."
draft: false
---
I also organized everything into folders:
content/posts/ β blog posts
static/images/ β optimized images
data/authors/ β author profiles
themes/mytheme/ β custom Hugo theme
layouts/ β templates
4. Image Optimization
Images were one of the biggest performance bottlenecks. I added an image processing step to:
- Resize large Notion images.
- Convert them to WebP for modern browsers.
- Apply lazy loading so pages load instantly.
This ensured the blog remained lightweight and fast, even with many media-heavy posts.
import os
from pathlib import Path
from PIL import Image
# Base directory of your project (two levels up from this script)
BASE_DIR = Path(__file__).resolve().parent.parent
# Configuration with absolute paths
INPUT_DIR = BASE_DIR / "output" / "images"
WEBP_QUALITY = 85
SKIP_IF_LARGER = True
def get_kb(path):
return os.path.getsize(path) / 1024
def crop_center_square(img):
w, h = img.size
min_dim = min(w, h)
left = (w - min_dim) // 2
top = (h - min_dim) // 2
return img.crop((left, top, left + min_dim, top + min_dim))
def crop_center_16_9(img):
w, h = img.size
target_ratio = 16 / 9
current_ratio = w / h
if current_ratio > target_ratio:
new_width = int(h * target_ratio)
left = (w - new_width) // 2
return img.crop((left, 0, left + new_width, h))
else:
new_height = int(w / target_ratio)
top = (h - new_height) // 2
return img.crop((0, top, w, top + new_height))
def process_image(file_path):
filename = os.path.basename(file_path)
name, ext = os.path.splitext(filename)
ext = ext.lower()
if ext not in [".jpg", ".jpeg", ".png"]:
return
input_kb = get_kb(file_path)
with Image.open(file_path) as img:
img = img.convert("RGB")
original_img = img
# Crop square
if "square" in name.lower():
img = crop_center_square(img)
# Crop 16:9
elif "thumb" in name.lower():
img = crop_center_16_9(img)
# Save WebP version
webp_path = os.path.splitext(file_path)[0] + ".webp"
img.save(webp_path, "WEBP", quality=WEBP_QUALITY)
webp_kb = get_kb(webp_path)
# Check if WebP is larger
if SKIP_IF_LARGER and webp_kb > input_kb:
os.remove(webp_path)
print(f"β οΈ Skipped: {filename} β {os.path.basename(webp_path)} | WebP was larger ({webp_kb:.1f} KB > {input_kb:.1f} KB)")
else:
os.remove(file_path)
report_change(filename, os.path.basename(webp_path), input_kb, webp_kb)
def report_change(original_name, new_name, original_kb, new_kb):
change_kb = new_kb - original_kb
percent_change = (change_kb / original_kb) * 100
if percent_change > 0:
print(f"β οΈ Increased: {original_name} -> {new_name} | {original_kb:.1f} KB β {new_kb:.1f} KB (+{percent_change:.1f}%)")
else:
print(f"β
Reduced: {original_name} -> {new_name} | {original_kb:.1f} KB β {new_kb:.1f} KB (-{abs(percent_change):.1f}%)")
# Process all images
def processImages():
for root, dirs, files in os.walk(INPUT_DIR):
for file in files:
process_image(os.path.join(root, file))
5. Automation with CI/CD
The final piece of the puzzle was automation. I wanted the entire publishing workflow to be hands-off after writing in Notion.
Using GitHub Actions, I built a CI/CD pipeline that connects directly with GitHub Pages:
- On every push to main, the workflow triggers automatically.
- Hugo builds the site into the
/publicdirectory. - The built site is deployed directly to GitHub Pages, making changes live within seconds.
This means:
β
No manual uploads or FTP transfers.
β
Every commit = a live blog update.
β
Continuous publishing β I write in Notion, export, commit, and everything else is automated.
π What Hugo Really Does
At its core, Hugo does one thing extremely well:
It takes Markdown and converts it into static HTML.
Example:
# My First Blog Post
Written in Notion β Exported β Processed β Built with Hugo.
Becomes:
<h1>My First Blog Post</h1>
<p>Written in Notion β Exported β Processed β Built with Hugo.</p>
The magic is in everything Hugo adds around that:
- Layouts and themes for consistent styling.
- Taxonomies for tags and categories.
- Partial templates for modular components like headers, footers, and sidebars.
- Asset pipelines for CSS and JS optimization.
All of this happens in milliseconds.
π Static Site Generation (SSG)
Traditional CMS systems (like WordPress) generate pages on demand, querying databases and rendering templates each time a visitor loads a page.
In contrast, Static Site Generators (SSG) like Hugo work differently:
- Pages are pre-built at compile time.
- The site is just static HTML, CSS, and JS files.
- Everything can be served directly from a CDN.
This gives:
- β‘ Instant load speeds (no backend queries).
- π‘οΈ Security (no dynamic server to hack).
- π Scalability (a static site can serve millions of requests with ease).
- π° Low cost (free hosting on GitHub Pages, Vercel, or Netlify).
This is why Hugo is such a great fit for modern publishing.
π The Final System
After weeks of iteration, I now have a fully automated publishing pipeline that turns messy Notion exports into a fast, production-ready blog:
- βοΈ Write in Notion.
- π¦ Export and process files into Hugo-ready Markdown.
- ποΈ Organize posts, images, and metadata into the right folders.
- π¨ Apply a custom Hugo theme with SEO, categories, tags, and author profiles.
- πΌοΈ Optimize images for the web (resizing, WebP, lazy loading).
- β‘ Hugo builds the site in seconds.
- π GitHub Actions deploys automatically to GitHub Pages.
- π The blog loads fast, scales effortlessly, and stays always up-to-date.
In short:
π‘ Think β βοΈ Write β π¦ Export β β‘ Build β π Deploy
Everything else is handled by the pipeline.
π Try It Yourself
π GitHub Repo β Notion-to-Hugo Automation
π Live Blog Demo
β¨ Final Thoughts
This wasnβt just about building a blog β it was about creating a publishing system that eliminates friction between writing and publishing.
Key takeaways from the pipeline:
- π¨ Custom Hugo theme for design, performance, and SEO.
- ποΈ Structured metadata & taxonomy for tags, categories, and author profiles.
- πΌοΈ Image optimization for lightweight, fast-loading pages.
- β‘ Hugo static site generation for near-instant builds.
- π GitHub Actions + GitHub Pages for zero-click deployment.
What began as a pile of scattered Notion notes is now a streamlined publishing engine β optimized, automated, and future-proof.
And the best part? Publishing is so fast and effortless that the blog feels lighter than air.