Background
After setting up a Hugo blog, choosing a theme can be tough. PaperMod is minimal and clean, Stack has a rich card-style layout with widgets — why not have both?
This article documents how to make two themes coexist and switch between them with a single script.
Key Differences Between the Two Themes
Before diving in, let’s understand what actually differs. The differences fall into three categories: configuration structure, content directories, and features.
Configuration Structure
PaperMod uses a single hugo.toml file:
1
2
3
4
5
6
| theme = 'PaperMod'
[params]
defaultTheme = "auto"
ShowReadingTime = true
ShowCodeCopyButtons = true
|
Stack uses a multi-file config/_default/ directory:
1
2
3
4
5
6
| config/_default/
├── hugo.toml # Base settings
├── params.toml # Theme params
├── menu.toml # Menu config
├── languages.toml # Languages
└── markup.toml # Markdown rendering
|
This means switching themes isn’t just changing one field — you need to swap the entire config.
Content Directories
| Item | PaperMod | Stack |
|---|
| Posts directory | content/posts/ | content/post/ (singular) |
| Standalone pages | Anywhere | content/page/ (dedicated) |
| Search page | Auto-generated | Needs content/page/search/index.md |
| Archives page | Auto-generated | Needs content/page/archives/index.md |
| Tags page | Auto-generated | Needs content/tags.md |
| Categories page | Auto-generated | Needs content/categories.md |
Features
| Feature | PaperMod | Stack |
|---|
| Navigation | Top header bar | Left sidebar (with icons) |
| Featured image | cover field | image field |
| Widget system | None | Search, archives, categories, tag cloud |
| Theme toggle | defaultTheme param | colorScheme.toggle |
| Menu icons | Not supported | params.icon |
| Hugo version | >= 0.146.0 | >= 0.157.0 |
Designing the Switching Solution
The switching mechanism needs to handle:
- Config swap — PaperMod uses root
hugo.toml, Stack uses config/_default/ - Content sync — Files between
posts/ and post/ - Stack-specific pages — Auto-create when switching to Stack, clean up when switching back
- Config backup — Save current config before switching, restore when switching back
Directory structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| blog/
├── configs/ # Backup directory
│ ├── papermod/hugo.toml # PaperMod config backup
│ └── stack/ # Stack config backup
│ ├── hugo.toml
│ ├── params.toml
│ ├── menu.toml
│ ├── languages.toml
│ └── markup.toml
├── config/_default/ # Current Stack config (runtime)
├── hugo.toml # Current PaperMod config (runtime)
├── content/
│ ├── posts/ # PaperMod posts directory
│ ├── post/ # Stack posts directory
│ └── page/ # Stack standalone pages
├── switch-theme.sh # Switching script
└── .current-theme # Current theme marker
|
Writing the Switch Script
Script Skeleton
Start with argument parsing and duplicate-switch prevention:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| #!/bin/bash
set -e
BLOG_DIR="$(cd "$(dirname "$0")" && pwd)"
THEME="${1:-}"
if [ -z "$THEME" ]; then
echo "Usage: $0 {papermod|stack}"
echo "Current theme: $(cat "$BLOG_DIR/.current-theme" 2>/dev/null || echo 'unknown')"
exit 1
fi
THEME=$(echo "$THEME" | tr '[:upper:]' '[:lower:]')
CURRENT=$(cat "$BLOG_DIR/.current-theme" 2>/dev/null || echo "unknown")
if [ "$CURRENT" = "$THEME" ]; then
echo "Already using $THEME theme."
exit 0
fi
|
Config Backup Function
Save current config before switching:
1
2
3
4
5
6
7
8
9
10
11
12
| save_config() {
local from="$1"
# Save PaperMod single-file config
if [ -f "$BLOG_DIR/hugo.toml" ]; then
cp "$BLOG_DIR/hugo.toml" "$BLOG_DIR/configs/$from/hugo.toml"
fi
# Save Stack multi-file config
if [ -d "$BLOG_DIR/config/_default" ]; then
mkdir -p "$BLOG_DIR/configs/$from"
cp -r "$BLOG_DIR/config/_default/"* "$BLOG_DIR/configs/$from/"
fi
}
|
Content Sync Function
Sync files between the two content directories:
1
2
3
4
5
6
7
8
| sync_content() {
local src="$1" dst="$2"
if [ -d "$BLOG_DIR/content/$src" ]; then
mkdir -p "$BLOG_DIR/content/$dst"
# -n: don't overwrite existing files
cp -n "$BLOG_DIR/content/$src/"*.md "$BLOG_DIR/content/$dst/" 2>/dev/null || true
fi
}
|
Using cp -n instead of cp -f prevents overwriting manual edits in the target directory.
Switch to PaperMod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| case "$THEME" in
papermod)
save_config "stack"
# 1. Remove Stack's multi-file config
rm -rf "$BLOG_DIR/config"
# 2. Restore PaperMod's single-file config
cp "$BLOG_DIR/configs/papermod/hugo.toml" "$BLOG_DIR/hugo.toml"
# 3. Sync posts: post/ -> posts/
sync_content "post" "posts"
# 4. Clean up Stack-specific pages
rm -f "$BLOG_DIR/content/tags.md"
rm -f "$BLOG_DIR/content/categories.md"
rm -rf "$BLOG_DIR/content/page"
;;
|
Switch to Stack
Stack requires creating several dedicated pages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| stack)
save_config "papermod"
# 1. Remove PaperMod's single-file config
rm -f "$BLOG_DIR/hugo.toml"
# 2. Restore Stack's multi-file config
mkdir -p "$BLOG_DIR/config/_default"
cp -r "$BLOG_DIR/configs/stack/"* "$BLOG_DIR/config/_default/"
# 3. Sync posts: posts/ -> post/
sync_content "posts" "post"
# 4. Create Stack-specific pages
mkdir -p "$BLOG_DIR/content/page/search"
mkdir -p "$BLOG_DIR/content/page/archives"
# Search page (with JSON output for search widget)
cat > "$BLOG_DIR/content/page/search/index.md" << 'EOF'
---
title: "Search"
slug: "search"
layout: "search"
outputs:
- html
- json
menu:
main:
weight: -80
params:
icon: search
---
EOF
# Archives page
cat > "$BLOG_DIR/content/page/archives/index.md" << 'EOF'
---
title: "Archives"
layout: "archives"
slug: "archives"
---
EOF
# Tags and categories pages
for f in tags categories; do
case "$f" in
tags) title="Tags"; layout="terms" ;;
categories) title="Categories"; layout="terms" ;;
esac
cat > "$BLOG_DIR/content/$f.md" << EOF
---
title: "$title"
layout: "$layout"
slug: "$f"
---
EOF
done
;;
esac
|
Finalize
Update the marker and prompt next steps:
1
2
3
4
5
6
| echo "$THEME" > "$BLOG_DIR/.current-theme"
echo "Done! Current theme: $THEME"
echo ""
echo "Next steps:"
echo " hugo server -D # Preview locally"
echo " ./deploy.sh # Build and deploy"
|
Stack Configuration Deep Dive
Since Stack’s config is more complex, here’s what each file does.
hugo.toml — Base Settings
1
2
3
4
5
6
7
8
9
10
11
12
| baseURL = "http://blog.quickpay.wang:8026/"
locale = "zh-cn"
title = "My Blog"
defaultContentLanguage = "zh"
hasCJKLanguage = true # Required for CJK languages
theme = "Stack"
[pagination]
pagerSize = 10
[permalinks]
post = "/p/:slug/" # Stack's URL format
|
hasCJKLanguage = true is critical — without it, Chinese word count and summary truncation break.
params.toml — Theme Parameters
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| mainSections = ["post"]
rssFullContent = true
[sidebar]
emoji = "✍️"
subtitle = "My blog subtitle"
[article]
toc = true
readingTime = true
[widgets]
homepage = [
{ type = "search" },
{ type = "archives", params = { limit = 5 } },
{ type = "categories", params = { limit = 10 } },
{ type = "tag-cloud", params = { limit = 10 } },
]
page = [{ type = "toc" }]
[colorScheme]
toggle = true
default = "auto"
|
Stack menus live in the left sidebar with icons:
1
2
3
4
5
6
7
8
| [[main]]
identifier = "home"
name = "Home"
url = "/"
weight = -100
[main.params]
icon = "home" # Icon from themes/Stack/assets/icons/
|
Available icons: home, search, tag, categories, archives, brand-github, etc. Check themes/Stack/assets/icons/ for the full list.
languages.toml — Multilingual
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| [zh]
label = "中文"
title = "My Blog"
weight = 1
[zh.params.sidebar]
subtitle = "Tech notes and life records"
[en]
label = "English"
title = "My Blog"
weight = 2
[en.params.sidebar]
subtitle = "Personal blog"
|
Note: Stack uses label not languageName (deprecated in Hugo 0.158.0+).
markup.toml — Markdown Rendering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [goldmark]
[goldmark.renderer]
unsafe = true
[tableOfContents]
startLevel = 2
endLevel = 4
ordered = true
[highlight]
codeFences = true
guessSyntax = true
lineNos = true
noClasses = false
|
Pitfalls
1. Wrong Icon Name
Stack menu icons must exist in themes/Stack/assets/icons/. I used category but the file is categories:
1
| ERROR Error: icon 'category.svg' is not found under 'assets/icons' folder
|
Fix: Check ls themes/Stack/assets/icons/ for available icon names.
2. Stale Root hugo.toml
After switching to Stack, if the root hugo.toml (PaperMod config) isn’t removed, Hugo loads both configs and shows deprecation warnings.
Fix: Remove root hugo.toml when using Stack, remove config/_default/ when using PaperMod.
3. TOML Array Syntax
The social config is easy to get wrong. This is wrong:
1
2
3
| [social] # ← This line is extra
[[social]] # ← Array table can't nest under same-name table
identifier = "github"
|
Correct — just use [[social]] directly:
1
2
3
4
5
6
7
| [[social]]
identifier = "github"
name = "GitHub"
url = "https://github.com/"
[social.params]
icon = "brand-github"
|
4. Search/Archives Page Warnings
Stack shows these during build:
1
2
| WARN Search page not found. Create a page with layout: search.
WARN Archives page not found. Create a page with layout: archives.
|
Even when the pages exist — it’s a Hugo rendering order issue. The widget tries to find pages before they’re generated. Doesn’t affect output — pages render correctly.
Usage
1
2
3
4
5
6
7
8
9
10
11
12
| # Check current theme
cat .current-theme
# Switch themes
./switch-theme.sh papermod
./switch-theme.sh stack
# Build and deploy
./deploy.sh
# Local preview
hugo server -D
|
The switch takes about 1 second (mostly file copying). Build time: PaperMod ~350ms, Stack ~500ms.
Summary
Hugo multi-theme switching is essentially swapping config files and directory structures. The main differences between themes are:
- Config format (single file vs multi-file directory)
- Content paths (
posts/ vs post/) - Auto-generated pages vs manual pages
A shell script wraps these differences into one command. The core logic is simple — backup, replace, sync, create — but automates the repetitive work of handling a dozen files on every switch.