Netlify Redirects for Hugo Sites: A Practical Cleanup Guide

How I cleaned stale Hugo URLs with direct Netlify 301 redirects, static _redirects rules, and sitemap validation.
A practical cleanup guide for Hugo sites on Netlify: when to use netlify.toml, when static/_redirects is enough, and how to avoid redirect chains while fixing stale URLs.

SERIES: Hugo SEO

Redirects look simple until they start showing up in Google Search Console.

On a static site, a bad redirect is easy to miss. The page might work in the browser. The new URL might return 200. The sitemap might look fine. But Google may still be holding old URLs from a previous site structure, old slugs, renamed posts, or older internal links.

That is exactly where Netlify redirects become useful.

This post is the practical redirect cleanup guide I wish I had written before fixing my own Hugo site. It follows the cleanup work from How I Fixed Google Search Console Indexing Issues With a Hugo and Netlify Site, but focuses only on the redirect layer.

The examples here come from my actual netlify.toml and static/_redirects files.

The Problem Redirects Solve

Hugo generates static files. Netlify serves them.

That sounds simple, but URLs can change for many reasons:

  • a post title changes
  • a front matter slug is corrected
  • old content was moved out of a folder
  • a category folder was removed from the public URL
  • a typo in a slug was fixed
  • an old /writing/ page became /post/
  • an API route needs to point to a Netlify Function

When those old URLs already exist in Google, social links, bookmarks, or other sites, deleting them without a redirect creates avoidable 404 noise.

The cleanup goal is not to make every old URL indexable. The goal is:

old URL -> direct 301 -> current canonical URL -> 200

No redirect chains. No duplicate pages. No old URLs in the sitemap.

Where Netlify Reads Redirects From

Netlify supports two file-based redirect locations:

  • _redirects
  • netlify.toml

For a Hugo site, static/_redirects is copied into the final published output as:

public/_redirects

That makes it a convenient place for simple one-line redirects.

The netlify.toml file stays at the project root and is better when the rule benefits from structured TOML syntax, branch deploy settings, functions configuration, or more complex routing.

Netlify’s redirect engine processes _redirects rules first, then netlify.toml rules. That ordering matters if the same source path appears in both files.

My Current Split

I use both files.

In my site, static/_redirects handles simple legacy public paths:

/writing/ /post/ 301
/writing /post/ 301
/connect/ /contact/ 301
/connect /contact/ 301
/about-us/ /about/ 301
/about-us /about/ 301

These are straightforward page moves. The old URL has one obvious destination.

For post-level cleanup, most of my rules live in netlify.toml:

[[redirects]]
    from = "/post/cyber-security/dark-web/"
    to = "/post/inside-the-dark-web/"
    status = 301

[[redirects]]
    from = "/post/docker-without-sudo/"
    to = "/post/how-to-execute-docker-commands-using-non-root-users/"
    status = 301

[[redirects]]
    from = "/post/installing-jupyter-notebook-with-virtualenv/"
    to = "/post/installing-jupyter-notebook-in-virtual-env/"
    status = 301

I prefer netlify.toml for these because it keeps redirect cleanup next to the Netlify build and functions configuration.

Rule 1: Redirect To The Canonical Hugo URL

The most important redirect decision is the target.

Do not redirect to the page that “looks close enough.” Redirect to the exact canonical URL Hugo currently generates.

For example, this old URL existed historically:

/post/cyber-security/dark-web/

The current post is:

/post/inside-the-dark-web/

So the redirect should be direct:

[[redirects]]
    from = "/post/cyber-security/dark-web/"
    to = "/post/inside-the-dark-web/"
    status = 301

I did not recreate the old /post/cyber-security/dark-web/ page. I also did not redirect it to the homepage. It has a real replacement, so it gets a direct 301.

Rule 2: Fix Typos As Redirects, Not As Permanent URL Debt

One issue in my site was a typo:

/post/api-authentication-and-authroization/

The corrected canonical URL is:

/post/api-authentication-and-authorization/

The redirect is simple:

[[redirects]]
    from = "/post/api-authentication-and-authroization/"
    to = "/post/api-authentication-and-authorization/"
    status = 301

There is also an older title-based URL:

[[redirects]]
    from = "/post/api-authentication-vs-authorization/"
    to = "/post/api-authentication-and-authorization/"
    status = 301

That gives Google, readers, and old links one clear answer: the corrected URL is the only page that should survive.

Rule 3: Avoid Redirect Chains

A redirect chain happens when an old URL points to another old URL, which then points to the current URL.

This is weaker:

/old-a/ -> /old-b/ -> /current/

This is better:

/old-a/ -> /current/
/old-b/ -> /current/

That is why my API redirects all point directly to the final page:

/api/                              -> /post/unsung-heros-of-interconnected-world-apis/
/posts/api/                        -> /post/unsung-heros-of-interconnected-world-apis/
/post/api/                         -> /post/unsung-heros-of-interconnected-world-apis/

The static/_redirects version handles old root-level and /posts/ variants:

/api/ /post/unsung-heros-of-interconnected-world-apis/ 301
/api /post/unsung-heros-of-interconnected-world-apis/ 301
/posts/api/ /post/unsung-heros-of-interconnected-world-apis/ 301
/posts/api /post/unsung-heros-of-interconnected-world-apis/ 301

The netlify.toml version handles the old Hugo post path:

[[redirects]]
    from = "/post/api/"
    to = "/post/unsung-heros-of-interconnected-world-apis/"
    status = 301

The destination is the same. That is intentional.

Rule 4: Keep Slash And Non-Slash Variants In Mind

In static/_redirects, I keep both versions for some older page routes:

/writing/ /post/ 301
/writing /post/ 301

I do this for short public routes that may have been shared either way.

For post URLs, I normally use the trailing slash version because Hugo’s canonical post URLs are slash-terminated:

/post/inside-the-dark-web/

The practical rule is:

  • for broad public pages, cover both if old links may exist
  • for Hugo posts, match the canonical shape Hugo actually serves

Rule 5: Use 200 Rewrites Only When You Mean Rewrite

Most old content URLs should use 301.

But this rule in my netlify.toml is different:

[[redirects]]
    from = "/api/*"
    to = "/.netlify/functions/:splat"
    status = 200

That is not an SEO redirect. It is a rewrite.

The browser keeps the /api/... URL, while Netlify serves the matching function behind the scenes.

That is useful for application routes and serverless APIs. It is not what I want for old article URLs.

For old articles, use 301.

For clean API paths backed by Netlify Functions, use 200.

Rule 6: Do Not Use force Unless You Need It

Netlify redirects have a force option. I avoided it for this cleanup.

A forced redirect can override existing content at the source path. That is useful in some situations, but it is also easy to misuse.

For stale post cleanup, I want a simpler rule:

  • if Hugo still generates a page at that path, fix the Hugo source first
  • if Hugo no longer generates a page at that path, add a direct redirect

That keeps redirects from hiding source problems.

How I Audit A Redirect Before Adding It

Before adding a redirect, I answer four questions.

First, does the old URL still exist in source?

rg -n "cyber-security/dark-web|docker-without-sudo|api-authentication-and-authroization" `
  "$SITE_ROOT\content" `
  "$SITE_ROOT\layouts" `
  "$SITE_ROOT\static" `
  "$SITE_ROOT\netlify.toml"

Second, what canonical URL does Hugo generate now?

hugo --source "$SITE_ROOT" `
  --config "$SITE_ROOT\hugo.toml" `
  --cleanDestinationDir `
  --minify

Third, does the final target page exist?

Test-Path "$SITE_ROOT\public\post\inside-the-dark-web\index.html"

Fourth, does the stale URL appear in the generated sitemap?

rg -n "cyber-security/dark-web|docker-without-sudo|api-authentication-and-authroization" `
  "$SITE_ROOT\public\sitemap.xml"

The ideal result:

  • old URL found only in redirect rules
  • canonical page exists in public/
  • stale URL does not appear in sitemap.xml
  • canonical URL appears in sitemap.xml

A Cleanup Pattern I Reuse

When I find a stale URL in Search Console, I use this decision tree:

1. Search the repo for the old slug.
2. If the old slug is still in content/front matter, fix the source.
3. If the old slug is still in layout/static links, fix the link.
4. If the page moved and has a real replacement, add a direct 301.
5. If the page has no replacement, let it 404.
6. Rebuild with --cleanDestinationDir.
7. Check the generated sitemap.
8. Deploy.
9. Test the old URL and final URL in Search Console.

The counterintuitive part is step 5. Not every old URL deserves a redirect. Redirecting deleted, unrelated pages to the homepage creates noise. It can also make debugging harder later.

My Current Redirect Examples

These are examples from my current cleanup set.

Renamed article:

[[redirects]]
    from = "/post/docker-without-sudo/"
    to = "/post/how-to-execute-docker-commands-using-non-root-users/"
    status = 301

Folder path removed from public URL:

[[redirects]]
    from = "/post/cyber-security/online-scams/"
    to = "/post/dont-click-that-link/"
    status = 301

Old title changed:

[[redirects]]
    from = "/post/ai-powered-workflow-for-building-websites/"
    to = "/post/from-idea-to-live-site-how-i-build-ai-powered-websites-in-a-day/"
    status = 301

Old section landing page:

/writing/ /post/ 301
/writing /post/ 301

Serverless function rewrite:

[[redirects]]
    from = "/api/*"
    to = "/.netlify/functions/:splat"
    status = 200

These rules are not all the same kind of redirect. That is the point. Article cleanup, navigation cleanup, and application rewrites should be treated differently.

What I Check After Deployment

After deployment, I do not immediately trust the Search Console Pages report. That report can lag.

Instead, I test the live behavior:

old URL -> 301
canonical URL -> 200
canonical URL appears in sitemap
old URL does not appear in sitemap

For important pages, I inspect the final canonical URL in Google Search Console and request indexing there.

I do not request indexing for old redirect-source URLs. Those URLs are supposed to disappear from Google’s index over time.

Redirect Cleanup Checklist

This is the version I would reuse on any Hugo and Netlify site:

1. Export stale URLs from Search Console.
2. Search the source tree for every old slug.
3. Confirm the canonical Hugo URL.
4. Prefer fixing front matter and internal links before adding redirects.
5. Add direct 301 redirects only to real replacement pages.
6. Keep redirect sources out of the sitemap.
7. Avoid redirect chains.
8. Avoid homepage redirects for unrelated deleted pages.
9. Use 200 rewrites only for app/API routing.
10. Rebuild with --cleanDestinationDir.
11. Test old and new URLs after deploy.
12. Validate in Search Console.

Final Takeaway

Good redirect cleanup is not about collecting old URLs forever.

It is about making the current site unambiguous.

For my Hugo site, the clean state is:

  • Hugo generates one canonical URL per article
  • old article URLs use direct 301 redirects
  • API paths use 200 rewrites only when they are meant to hit functions
  • _redirects handles simple legacy public paths
  • netlify.toml handles structured Netlify routing
  • the sitemap contains only current URLs

Once that is true, Search Console can take its time catching up. The site itself is already sending the right signals.

References


Work Behind The Writing

This article comes from real-world AI and DevOps engineering work.

If the thinking here is useful, explore the projects behind it or get in touch about a similar technical problem.
comments powered by Disqus