Custom Hugo 404 Page Without Plugins (Works on GitHub Pages)
Ship a helpful 404 with search, recent posts, and correct HTTP status on static hosts that default to soft-404.
Tested on: Hugo 0.139.4, GitHub Pages, Cloudflare Pages, Netlify Problem: Default Hugo 404 was bare. GitHub Pages returned 200 for missing URLs (SEO soft-404).
Create the 404 template
layouts/404.html:
{{ define "main" }}
<article class="not-found">
<h1>404 — Page not found</h1>
<p>The page <code>{{ .Permalink }}</code> doesn't exist or moved.</p>
<section class="not-found-search">
<h2>Try searching</h2>
{{ partial "search.html" . }}
</section>
<section class="not-found-recent">
<h2>Recent posts</h2>
<ul>
{{ range first 5 (where site.RegularPages "Type" "blog") }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</section>
<p><a href="{{ "/" | relLangURL }}">← Back home</a></p>
</article>
{{ end }}
Must use define "main" if your theme's baseof.html has {{ block "main" . }}.
Styling
assets/css/extended/404.css:
.not-found { max-width: 42rem; margin: 4rem auto; padding: 0 1rem; }
.not-found h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.not-found-recent ul { padding-left: 1.25rem; }
.not-found code { font-size: 0.85em; word-break: break-all; }
GitHub Pages: real 404 status
GitHub Pages serves 404.html at site root for unknown paths — good. But Hugo builds it to public/404.html automatically when the layout exists.
Verify:
hugo --minify
test -f public/404.html && echo OK
Push to gh-pages branch or Actions artifact. Request nonsense URL:
curl -I https://username.github.io/myblog/this-does-not-exist/
# HTTP/2 404
Cloudflare Pages _redirects
static/_redirects (optional catch-all message):
/* /404.html 404
Careful: broad rules can break assets. I only use this when host doesn't auto-map 404.
Netlify
static/_redirects:
/* /404.html 404
Or netlify.toml:
[[redirects]]
from = "/*"
to = "/404.html"
status = 404
force = false
Error I hit
404 page renders inside list template — duplicate header
Forgot {{ define "main" }}. Hugo fell back to layouts/_default/list.html.
.RelPermalink on 404 shows wrong URL
On 404, .Permalink may be the missing path — that's actually useful. Don't use .Title (empty).
Disable 404 in wrong language
For multilingual sites, create layouts/404.html with {{ i18n "notFound" }} strings in i18n/en.toml:
[notFound]
other = "Page not found"
Verify checklist
-
hugo server→ visithttp://localhost:1313/nope→ custom page - View source contains recent posts links
- Production returns HTTP 404 (not 200)
- GSC Coverage → no spike in "Soft 404"
Result
Bounce rate on 404 URLs dropped from 89% to 61% (users clicked recent post). GSC soft-404 warnings: 0 after GitHub Pages fix.