Hugo Multi-Theme Switching: PaperMod vs Stack

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

ItemPaperModStack
Posts directorycontent/posts/content/post/ (singular)
Standalone pagesAnywherecontent/page/ (dedicated)
Search pageAuto-generatedNeeds content/page/search/index.md
Archives pageAuto-generatedNeeds content/page/archives/index.md
Tags pageAuto-generatedNeeds content/tags.md
Categories pageAuto-generatedNeeds content/categories.md

Features

FeaturePaperModStack
NavigationTop header barLeft sidebar (with icons)
Featured imagecover fieldimage field
Widget systemNoneSearch, archives, categories, tag cloud
Theme toggledefaultTheme paramcolorScheme.toggle
Menu iconsNot supportedparams.icon
Hugo version>= 0.146.0>= 0.157.0

Designing the Switching Solution

The switching mechanism needs to handle:

  1. Config swap — PaperMod uses root hugo.toml, Stack uses config/_default/
  2. Content sync — Files between posts/ and post/
  3. Stack-specific pages — Auto-create when switching to Stack, clean up when switching back
  4. 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:

  1. Config format (single file vs multi-file directory)
  2. Content paths (posts/ vs post/)
  3. 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.