Migrating From Hugo to Astro Without Breaking URLs

Hugo builds a thousand pages in seconds and asks nothing of you at runtime. The reasons to leave it are almost always about the page itself rather than the build: a documentation site that needs interactive components, a design system shared with a product application, a content model you want the build to validate rather than hope about.

The risk in this particular move is URLs. Hugo derives paths from configuration and directory structure; Astro derives them from file locations under src/pages/. Left alone, the two produce different URL schemes, and a docs site that quietly changes every path loses its inbound links. This guide keeps them identical. It is one route through Migrating Between Static Site Generators.

Prerequisites

  • Node 20+ and Astro 4 or newer, with the MDX integration if any content uses shortcodes.
  • The Hugo site building locally plus its production sitemap saved as urls-before.txt.
  • A list of the shortcodes actually used, from the census in the parent guide.

Decide the Routing Contract First

Hugo config-driven routing versus Astro file-driven routing Hugo derives a URL from a permalink pattern in configuration applied to a content file, so the source path and the URL are independent. Astro derives a URL from the file path under src slash pages. A dynamic route reading an explicit permalink field bridges the two, keeping URLs independent of file layout. Two different sources of truth for a URL Hugo content/docs/deploy.md + permalinks config /guides/deploy/ the URL readers have Astro, naively src/pages/docs/deploy.md → /docs/deploy/ ✗ Astro with an explicit permalink field src/pages/[...permalink].astro reads front matter → /guides/deploy/ ✓ regardless of file location Writing the old URL into front matter during conversion makes the contract explicit and testable
File-based routing is convenient until it disagrees with the URLs you already published. An explicit permalink field decouples the two and turns the contract into data you can assert on.

The Recipe

1. Extract the real URL for every page

Hugo already knows every URL it produces. Ask it, rather than reverse-engineering the config:

{{/* layouts/_default/urlmap.json — output format producing a source→URL map */}}
{{- $out := slice -}}
{{- range .Site.RegularPages -}}
  {{- $out = $out | append (dict "file" .File.Path "url" .RelPermalink "title" .Title) -}}
{{- end -}}
{{- $out | jsonify -}}

Register a custom output format for the home page and run hugo once; you now have public/urlmap.json, an authoritative mapping produced by the generator itself rather than by your reading of its documentation.

2. Convert content, writing the URL into front matter

// scripts/hugo-to-astro.mjs
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';

const map = JSON.parse(await readFile('public/urlmap.json', 'utf8'));

for (const entry of map) {
  const src = path.join('content', entry.file);
  const { data, content } = matter(await readFile(src, 'utf8'));

  const body = content
    // {{< note >}}…{{< /note >}}  →  <Note>…</Note>
    .replace(/\{\{<\s*(\w+)\s*>\}\}/g, '<$1>')
    .replace(/\{\{<\s*\/(\w+)\s*>\}\}/g, '</$1>')
    // {{< figure src="x" caption="y" >}} → <Figure src="x" caption="y" />
    .replace(/\{\{<\s*figure\s+([^>]+?)\s*\/?>\}\}/g, '<Figure $1 />');

  const usesComponents = /<[A-Z]/.test(body);
  const out = path.join('src/content/docs',
    entry.file.replace(/\.md$/, usesComponents ? '.mdx' : '.md'));

  await mkdir(path.dirname(out), { recursive: true });
  await writeFile(out, matter.stringify(body, {
    title: data.title,
    description: data.description,
    permalink: entry.url,          // ← the contract, carried explicitly
    weight: data.weight ?? 100,
    draft: data.draft ?? false,
  }));
}

Only pages that actually use components become MDX. Mixed collections are fine, and MDX parses noticeably more slowly, so converting everything costs build time for no benefit.

3. Define the schema, and let the build enforce it

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const docs = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string().max(70),
    description: z.string().min(80).max(158),
    permalink: z.string().regex(/^\/.*\/$/),   // must be absolute, trailing slash
    weight: z.number().default(100),
    draft: z.boolean().default(false),
  }),
});

export const collections = { docs };

This is the main thing Astro gives you that Hugo does not: a build that fails, with a filename, when a page is missing a description or has a malformed URL. Run it once against the converted corpus and it will find every content defect accumulated over the site's life in a single pass.

---
// src/pages/[...permalink].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';

export async function getStaticPaths() {
  const docs = await getCollection('docs', ({ data }) => !data.draft);
  return docs.map((entry) => ({
    params: { permalink: entry.data.permalink.replace(/^\/|\/$/g, '') },
    props: { entry },
  }));
}

const { entry } = Astro.props;
const { Content } = await entry.render();
---
<BaseLayout title={entry.data.title} description={entry.data.description}>
  <Content />
</BaseLayout>

Every URL now comes from data, so reorganising src/content/ later cannot move a single page.

5. Gate parity in CI

npm run build
find dist -name '*.html' | sed 's|dist||; s|/index.html|/|' | sort > urls-after.txt
comm -23 urls-before.txt urls-after.txt > missing.txt
if [ -s missing.txt ]; then echo "MISSING URLS:"; cat missing.txt; exit 1; fi
comm -13 urls-before.txt urls-after.txt > added.txt   # informational, not a failure
echo "URL parity OK ($(wc -l < urls-after.txt) pages)"

6. Turn shortcodes into components you actually want

A Hugo shortcode is a template that renders HTML; an Astro component is a template that renders HTML and can optionally hydrate. The conversion is usually a simplification, because most shortcodes exist to work around the absence of components in the first place.

---
// src/components/Note.astro — replaces {{< note >}}…{{< /note >}}
const { type = 'note' } = Astro.props;
const label = { note: 'Note', warn: 'Warning', tip: 'Tip' }[type] ?? 'Note';
---
<aside class={`callout callout-${type}`}>
  <strong>{label}:</strong>
  <slot />
</aside>

Register the components your content uses globally so authors do not have to import them in every file, and keep the invocation names identical to the old shortcode names wherever possible. A writer who has typed <Note> for three years should not have to learn <Callout type="note"> as part of a migration they did not ask for.

This is also the point where a genuinely interactive component becomes possible: an API request builder, a configuration generator, a live example. Add a client:visible directive and the component hydrates when it scrolls into view, while the rest of the page stays static HTML. That per-component control is the actual reason to be on Astro, and it is worth building one such component during the migration to prove the pipeline works end to end — the trade-offs are laid out in Astro Islands vs Full Hydration Performance.

Hugo shortcode compared with an Astro component A Hugo shortcode renders HTML at build time only. An Astro component renders the same HTML at build time and can additionally hydrate on the client when a client directive is present, so the same authoring syntax covers both static and interactive cases. Same call site, one extra capability Hugo shortcode build-time HTML only static HTML 0 KB JavaScript Astro component build-time by default static HTML 0 KB JavaScript + client:visible hydrates on scroll Pages that need nothing interactive ship exactly what Hugo shipped — the capability is opt-in per component
Nothing hydrates unless you ask it to, which is why a converted docs site still ships zero JavaScript on the pages that were plain documents before.

7. Settle trailing slashes, case and index pages

Three small details account for most of the "URL parity passed but production 404s" reports after this migration.

Trailing slashes. Hugo emits directory-style URLs ending in a slash by default. Astro's trailingSlash option accepts always, never or ignore, and the build output differs accordingly. Set it to match what your old site served, and set the same policy at your host, because a mismatch between the two turns every internal link into a redirect — cheap individually, and a measurable delay on a page with forty of them.

Case sensitivity. Hugo lowercases path segments derived from titles; a hand-written Astro route does not. If any old URL contains an uppercase letter, it must be reproduced exactly, and a case-insensitive local filesystem will happily hide the problem until it reaches a Linux host.

Index pages. Hugo's _index.md becomes a section list page; Astro needs an explicit index.astro or an entry whose permalink ends at the section root. These are easy to forget precisely because they are the pages nobody links to by filename — and they are usually the most-linked URLs on the site.

Run the parity gate on a case-sensitive filesystem, with the host's trailing-slash policy applied, before believing it. The cheapest way to do that is to run it against a real preview deployment rather than a local dist/ directory, which is one more argument for having per-pull-request previews in place before starting.

8. Keep the sitemap and search index honest

A migration changes two things search engines care about: the pages that exist and the internal links between them. Regenerate the sitemap from the new build rather than copying the old one — a stale sitemap that lists URLs the new build does not produce is a slow, quiet source of crawl errors.

If the site has a client-side search index, rebuild it as part of the same pipeline and check its document count against the page count. An index built from the old content directory will keep returning results that point at URLs the new site no longer serves, and because search results are generated at runtime, no build-time link check will catch it.

Finally, resubmit the sitemap after the last section cuts over and watch coverage for a fortnight. What you are looking for is not a ranking change — those take longer and have many causes — but a spike in "not found" or "redirect" statuses, which almost always traces to one pattern rather than to individual pages.

Measured Impact

A 900-page documentation site, GitHub Actions ubuntu-latest, cold cache, median of five runs:

MetricHugo 0.128Astro 4Change
Cold build4.1 s71 s17× slower
Dev server rebuild (one page)0.28 s0.42 sComparable
CI wall clock incl. install48 s2 m 40 s+233%
JS shipped to the reader (median page)0 KB0 KBUnchanged
Content defects found by the scheman/a63 pages
What the migration costs and what it buys Two columns. The cost column shows a cold build rising from 4.1 seconds to 71 seconds and CI wall clock rising from 48 seconds to 2 minutes 40. The benefit column shows a typed schema catching 63 content defects, component islands available per page, and a shared design system with the product application. A deliberate trade, not an upgrade Costs cold build 4.1 s → 71 s CI 48 s → 2 m 40 s node_modules to maintain MDX parsing on component pages a JS toolchain in the deploy path Benefits schema caught 63 content defects islands where interactivity is needed design system shared with the app still 0 KB JS on plain pages typed front matter in the editor If none of the right-hand column applies to your site, this migration is a downgrade — stay on Hugo
Build time is the price. It is worth paying when the site genuinely needs components or content validation, and not otherwise.

The last row deserves emphasis. Turning on the schema surfaced 63 pages with missing or over-long descriptions — defects that had been invisible for years and were affecting search snippets. That single pass paid for a meaningful share of the migration effort, and it is the kind of check worth keeping in any content pipeline, as discussed in Choosing an SSG for API Reference Documentation.

Budgeting for the slower build

A 17× build-time regression is the number that surprises teams after cutover, and it is worth planning for rather than absorbing. Three levers recover most of it in CI. Cache node_modules and Astro's own build cache between runs, which turns a cold two-minute install into a warm twenty seconds. Split preview builds from production builds, so a pull request only builds what it needs. And avoid MDX where plain Markdown will do, since MDX parsing is the single largest per-page cost in an Astro content build.

For local work the situation is better than the cold-build number suggests: the dev server rebuilds a single page in well under half a second, so an author editing prose sees the same responsiveness they had before. The regression lands almost entirely on CI, which means it is a pipeline problem with pipeline solutions rather than something writers experience daily.

Pitfalls & Rollback

  • Letting file paths define URLs. The default routing is convenient and wrong for a migration. Route from the permalink field.
  • Converting everything to MDX. It slows the build and gains nothing on pages with no components.
  • Trusting a hand-written URL map. Generate it from Hugo itself; config patterns interact in ways that are easy to misread.
  • Forgetting trailing-slash policy. Astro's trailingSlash setting must match what your old site served, or every URL is a redirect.
  • Ignoring the build-time regression in CI. A 3× longer pipeline changes how often people deploy; budget for caching before it becomes a complaint.
  • Rollback: keep the Hugo site building in the same repository until the last section is cut over. Reverting is re-pointing the edge route at the Hugo deployment.

Conclusion

The technical work in a Hugo-to-Astro migration is ordinary; the discipline is keeping URLs as data rather than as a side effect of file layout. Generate the URL map from Hugo, carry each permalink into front matter, route from that field, and gate parity in CI. Then decide honestly whether the schema and the components are worth the build time — and if they are not, staying on Hugo is a perfectly good outcome. The wider process is in Migrating Between Static Site Generators.

FAQ

Why move from Hugo to Astro at all?

For component-level interactivity and a typed content model. Hugo is faster to build and perfectly good for pure documents; Astro earns its slower build when pages need real components, islands of client-side behaviour, or a schema that fails the build when content is malformed.

Drive routing from an explicit permalink field in front matter rather than from file paths. A dynamic route that reads that field and emits the exact path gives you a URL scheme independent of how the source files are organised.

Will my build get slower?

Yes, substantially. Astro renders through a JavaScript toolchain rather than a Go binary, so expect roughly an order of magnitude more build time on a large corpus. Incremental dev builds stay fast, so writers notice much less than CI does.

What replaces Hugo shortcodes?

MDX components. A shortcode becomes an Astro component imported into the page or registered globally, and the invocation syntax changes from angle-bracket shortcode calls to JSX-style tags.

Do I need to convert all content to MDX?

No, and you should not. Keep plain Markdown for pages that only need text, and convert to MDX only those pages that actually use components. Mixed collections are supported and MDX parses more slowly.