As we know, cool URIs don’t change. But as we also know, over the years, content will move. When this happens, it’s polite to leave a redirect at the old URL to tell visitors the new location for the content.

Hugo makes it easy to define redirects using a frontmatter property called aliases:

---
# ... other frontmatter ...
slug: '/new-url'
aliases: ['/old-url']
---

Hugo will then generate two files:

  • /new-url/index.html (the post)
  • /old-url/index.html (the redirect)

For example, here’s a link to an alias URL:

https://blog.luketurner.org/hack-language-parser-in-a-single-regex

Note that after you click it, you get redirected to the correct URL for the post.

This recipe is about implementing the aliases frontmatter key with Astro.

There’s three common ways to trigger a redirect to a new page:

  1. The HTTP server returns a 3xx status code.
  2. The HTML <meta http-equiv="refresh"> element.
  3. Custom JavaScript, e.g. mutating window.location.

If we want to use static-site generation (SSG) mode, (1) isn’t an option since it requires server support. And since we want to avoid dependencies on JS when possible, (3) is a non-starter as well. That leaves us with option (2): using <meta http-equiv="refresh">. That’s what Hugo uses as well.

But, if you’re using Server-Side Rendering (SSR), then returning a 301 status code with Astro.redirect() would be a preferable option.

The Recipe

This section has the full code for the recipe. Read on to the next section for a more detailed explanation.

Put the following in src/pages/[...alias].astro:

---
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';

export async function getStaticPaths() {
  const posts = await getCollection('posts');
  const aliases = new Map();
  for (const entry of posts) {
    for (const alias of entry.data.aliases || []) {
      aliases.set(alias.replace(/^\//, ''), entry);
    }
  }

  return Array.from(
    aliases.entries(),
    ([alias, entry]) => ({
      params: { alias },
      props: {
        title: entry.data.title,
        dest: `/posts/${post.slug}`
      }
    })
  );
}


const { dest, title } = Astro.props;
const replacement = `${Astro.url.origin}${dest}`;
---

<Layout title={title}>
  <Fragment slot="head">
    <meta name="robots" content="noindex">
    <meta http-equiv="refresh"
          content={`5; url=${replacement}`}>
  </Fragment>

  <p>This page has permanently moved. The new URL is:</p>
  
  <p><a href={replacement}>{replacement}</a></p>

  <p>You should be automatically redirected in a few seconds...</p>
</Layout>

A few assumptions in the code above:

  • You have a src/layouts/Layout.astro layout that accepts a title prop and a head slot.
  • Your posts are stored in a posts content collection and use URLs like /posts/:slug.
  • All posts have a title frontmatter field.

The Explanation

Let’s go through the code piece by piece.


First, we’ll look at the filename itself:

src/pages/[...alias].astro

Astro uses filesystem-based routing. The cryptic [...alias].astro filename is using a rest parameter in the root pages/ directory, so this page’ll be able to match any URL. This is what allows us to use a single page to handle all our aliases at once.


Next, the imports:

import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';

A src/layouts/Layout.astro layout is assumed to be present in our site.

Here’s an example of the minimal Layout.astro:

---
export interface Props {
  title: string;
}

const { title } = Astro.props;
---

<!DOCTYPE html>
<html>
  <head>
    <title>{title}</title>
    <slot name="head" />
  </head>
  <body>
    <slot />
  </body>
</html>

Next:

export async function getStaticPaths() {

The getStaticPaths function is expected to return an array of objects, one for each generated page. Read more in the Astro reference.

In our case, we’ll want to generate one page per alias, so this function should return one object per alias.


Next:

const posts = await getCollection('posts');

Retrieve all the posts in our content collection.

If your site uses draft flags or other types of filters, remember to include those here as well!


Next:

const aliases = new Map();
for (const entry of posts) {
  for (const alias of entry.data.aliases || []) {
    aliases.set(alias.replace(/^\//, ''), entry);
  }
}

This bit of code inspects the aliases frontmatter in each post and generates a map from the alias URLs to the post entry objects.

In other words, we’re generating an index from each alias to its corresponding post.

Also, the alias.replace(/^\//, '') part strips any initial / from the alias, for consistency when concatenating paths later.


Next:

  return Array.from(
    aliases.entries(),
    ([alias, entry]) => ({
      params: { alias },
      props: {
        title: entry.data.title,
        dest: pathForPost(entry) 
      }
    })
  );
}

Here, we take our aliases index, and convert it into the return format expected by getStaticPaths(), which is:

  • An array of objects — one for each alias — with params and props keys.
  • The params key is an object like: { alias: 'old-post-url' }
  • The props key is an object like: { title: 'My post', dest: 'posts/new-post-url' }

The params.alias value is expected because of the way we’ve named our file [...alias].astro.

The props.title and props.dest values are used in rendering the page, as we’ll see below.


Next is the actual content of the redirect pages:

const { dest, title } = Astro.props;
const replacement = `${Astro.url.origin}${dest}`;
---

<Layout title={title}>
  <Fragment slot="head">
    <meta name="robots" content="noindex">
    <meta http-equiv="refresh"
          content={`5; url=${replacement}`}>
  </Fragment>

  <p>This page has permanently moved. The new URL is:</p>
  
  <p><a href={replacement}>{replacement}</a></p>

  <p>You should be automatically redirected in a few seconds...</p>
</Layout>

Many of the details here are specific to the way your app renders. The important part is the <meta http-equiv="refresh"> tag:

<meta http-equiv="refresh"
      content={`5; url=${replacement}`}>

This directive tells the browser, “redirect the user to {replacement} in five seconds”!

You may wonder: Why use a <Layout> component at all? Could we just render a simple HTML document, and redirect the user instantly?

Something like this:

<!DOCTYPE html>
<html>
<head>
<meta name="robots" content="noindex">
<meta http-equiv="refresh"
      content={`0; url=${replacement}`}>

This does work! The issue is that <meta http-equiv="refresh">-based redirects are triggered after the initial page is rendered, causing a “flash of content” effect that can be especially jarring with a blank page.

By using the same <Layout> as the rest of the site for redirect pages, we minimize the jar from the flash.

This is also why we use the correct title for the page and include some explanatory text about the redirect — to make it clear to the user what’s happening and why.