In July 2024 I decided to rewrite css-art.angelika.me from Vue to Astro.

#Why?

  • I wanted to learn Astro.
  • The app was using outdated technologies - Vue 2 (EOL: 31.12.2023) and prerender-spa-plugin.
  • A SPA framework was not the correct technology choice for the app to begin with. The app was mostly static content with minimal client-side functionality. I started the project in 2019 and chose Vue mostly to have a nice templating language for HTML.

#Features to recreate

I wanted to recreate the app and its features as closely as possible so that comparing the code before and after makes sense. The features:

  1. All pages are prerendered.
  2. Client-side redirect from the root path to one specific page.
  3. A color picker that keeps state when pages change.
  4. Pressing left/right arrows takes you to the previous/next page.

Here’s how I achieved each feature in Astro.

#1. All pages are prerendered

Astro prerenderes pages by default (SSG mode). No extra configuration or plugins necessary.

I created a pages/[...slug].astro file that exports a getStaticPaths function to define dynamic routes. I used a rest parameter ([...slug] instead of [slug]) in the file name so that it would also match the root path (slug: undefined).

I defined my routes in a separate routes.js file. To pass additional data to each page, I used a props value on the path object. Props are not encoded into the URL so they can be of any type - including Astro components. This allowed me to recreate a path to component mapping typical of SPA framework routers.

---
// pages/[...slug].astro

import Layout from '../layouts/Layout.astro';
import SquareCanvas from "../layouts/SquareCanvas.astro";
import routes from "../routes"

export function getStaticPaths() {
  return routes;
}

const { slug } = Astro.params;
const { title, createdOn, component } = Astro.props;

const Component = component;
---
<Layout title={title} slug={slug}>
  <SquareCanvas title={title} date={createdOn}>
    <Component />
  </SquareCanvas>
</Layout>
// routes.js

import Squares from "./components/Squares.astro";
import Triangles from "./components/Triangles.astro";
// and so on...

let routes = [
  {
    params: { slug: "squares" },
    props: { title: "squares", createdOn: "2019-11-26", component: Squares },
  },
  {
    params: { slug: "triangles" },
    props: { title: "triangles", createdOn: "2019-11-26", component: Triangles },
  },
  // and so on...
];

export default routes;

With the above code, running npm run build should produce:

 generating static routes
14:03:11 ▶ src/pages/[...slug].astro
14:03:11   ├─ /squares/index.html (+2ms)
14:03:11   └─ /triangles/index.html (+2ms)

#2. Client-side redirect from the root path to one specific page

My app has a redirect from the root path to one specific page. This redirect is not meant to be permanent, and the root path is supposed to get indexed by search engines. Whenever I add a new page, I change the redirect to point to the new page.

Astro has a redirect feature but it’s meant for permanently moved pages. It will output HTML files with the meta refresh tag.

To have a temporary redirect that behaves like a redirect in Vue Router, I needed to recreate it myself. Note that I’m using the term “redirect” loosely here, in a SPA context. It’s not about a “302 Found” server response, but rather a client-side window location change.

Let’s say I want to redirect from / to /14-segment temporarily. To achieve that, I need to:

  1. Define a static Astro path for / (slug: undefined) that has the same params as the path /14-segment. For this to work, I had to use a rest parameter in my .astro filename ([...slug].astro).
  2. Replace the browser history entry when the user visits / with /14-segment. There is a astro:page-load lifecycle event that can be used to run code every time the user navigates to a new page.

Those two steps can be achieved with the following code:

// routes.js

const rootRedirect = "14-segment";

let routes = [
  {
    params: { slug: "14-segment" },
    props: { title: "14-segment", createdOn: "2023-11-24", component: FourteenSegment },
  },
  // and so on...
];

const rootRoute = routes.find((r) => r.params.slug === rootRedirect);

routes = [...routes, { ...rootRoute, params: { slug: undefined } }];

export default routes;
export { rootRoute };
---
// pages/[...slug].astro

import Layout from '../layouts/Layout.astro';
import routes, { rootRoute } from "../routes"
import SquareCanvas from "../layouts/SquareCanvas.astro";

export function getStaticPaths() {
  return routes;
}

const { slug } = Astro.params;
const { title, createdOn, component } = Astro.props;

const Component = component;
---

<script>
  import { rootRoute } from '../routes'
  document.addEventListener('astro:page-load', () => {
    if (window.location.pathname === '/') {
      history.replaceState({}, '',  rootRoute.params.slug)
    }
  })
</script>

<Layout title={title} slug={slug || rootRoute.params.slug}>
  <SquareCanvas title={title} date={createdOn}>
    <Component />
  </SquareCanvas>
</Layout>

#3. A color picker that keeps state when pages change

My app has a color picker with a default value. The color picker allows you to change the accent color used on each page, and the value of the color picker should persist when pages change. Every time the value of the color picker changes, the value of an --accent-color CSS variable on the root element has to change.

The CSS variable on the root element posed a challenge. Astro’s client-side navigation (SPA mode) is different from what you would expect from a Vue or React SPA. Astro exchanges the whole DOM when navigation happens, and optionally allows you to keep state of some components using a transition:persist attribute. There is no global app state that would be persisted in memory either.

In a Vue app, I could rely on the root element and its CSS variables not getting modified when navigation happens. In an Astro app, everything resets when pages change.

I had to save the value of the color picker somewhere that would survive navigation (e.g. session storage), and restore the root element CSS variable every time the page changes. I also have to re-attach the change event handler to the input every time the page changes (like you do in a classic MPA).

// ColorPicker.astro
<script>
  function setup() {
    const root = document.querySelector(':root');
    const input = document.querySelector("#accent-color");
    const storageKey = 'accent-color';
    const colorToRestore = window.sessionStorage.getItem(storageKey) || '';

    input.value = colorToRestore;
    setColor(colorToRestore);

    function setColor(color) {
      root.style.setProperty('--accent-color', color);
      window.sessionStorage.setItem(storageKey, color)
    }

    input.addEventListener('change', (event) => {
      setColor(event.target.value)
    })

    setColor(input.value)
  }

  // on initial load, set the styles as fast as possible
  // (this could also have been done with astro:page-load,
  // but I noticed that it's slower than DOMContentLoaded)
  document.addEventListener('DOMContentLoaded', setup)
  // then, set it again after each page swap
  document.addEventListener('astro:after-swap', setup)
</script>

With this code in place, I still had flashes of unstyled content in my app. The app would render with the --accent-color variable unset, rendering unexpected black or transparent areas that would turn red (default value) after a second.

To prevent this, I decided to set the --accent-color variable on the root element as soon as possible, in a render-blocking script. This was a trade-off I was willing to make (prettier website, slightly slower rendering). Setting the variable required only the root element (<html>) to be already present in the DOM, so I could add an inline script right after the document’s head.

In Astro, any <script> tag that’s part of your Astro component will get extracted into the JS bundle and the bundle will be injected into your page’s <head> as a module. To opt out of this behavior and create a render-blocking inline script, you can use an is:inline attribute on the script.

// Layout.astro
<html>
<!-- head omitted -->
<script is:inline>
  // this script executes only once, and is render-blocking
  // the goal is to set the desired accent color before rendering
  // so that there is no flash of content without the accent color
  const root = document.querySelector(':root');
  const storageKey = 'accent-color';
  const savedColor = window.sessionStorage.getItem(storageKey);

  const defaultColor = '#e20909'
  const colorToRestore = savedColor || defaultColor;
  root.style.setProperty('--accent-color', savedColor || defaultColor);
  window.sessionStorage.setItem(storageKey, colorToRestore)
</script>
<body>
<!-- ... -->

#4. Pressing left/right arrows takes you to the previous/next page

Rewriting this feature from Vue to Astro was less about rewriting it to Astro, and more about rewriting it to vanilla JS.

In both apps, the feature works by finding a list of all links in the <nav> element, finding the link to the current page, and then navigating to the previous or next link.

In the Vue app, I relied on this.$router.currentRoute.path to find out what is the current path, and thus the current link. In the Astro app, I decided to use the aria-current attribute on the links that should be present on a well-implemented accessible navigation anyway. I could have done the same in my Vue app all those years ago if I had been better educated in web accessibility then.

I set aria-current on the link in the navigation component:

// Navigation.astro
{navLinks.misc.map(link =>
  <a href={link} aria-current={link === `/${slug}` ? "page" : "false"}>
    {/* finding link text based on routes omitted... */}
  </a>
)}

But that was not enough.

I was using view transitions and persisting the state of the whole navigation component during transitions (<nav id="nav" transition:persist>) to prevent resetting the focus state for users navigating with the keyboard. This meant I couldn’t rely on the navigation component to be re-rendered to recalculate aria-current. I had to update the values of aria-current myself, on each page load.

// Navigation.astro
<script>
  const links = Array.from(document.querySelectorAll('#nav a'))

  document.addEventListener('astro:page-load', () => {
    links.forEach(link => {
      const currentPage =
        link.getAttribute('href') ===
          document.location.pathname.replace(/\/$/, "")

      link.setAttribute(
        'aria-current',
        currentPage ? "page" : "false"
      )
    })
  })
</script>

Now that I had the list of all links and could rely on the link of the current page being identifiable with aria-current="page", I could easily find the previous or next link. Then I could read the link’s href attribute and pass that along to Astro’s navigate function.

// Navigation.astro
<script>
  import { navigate } from 'astro:transitions/client';

  // this function would be called from an onKeyUp event handler
  function visitLink(shift = 1) {
    const linkToVisit = findLinkToVisit(shift)
    navigate(linkToVisit.getAttribute('href'))
  }

  // some code emitted...

  document.addEventListener('keyup', onKeyUp)
</script>

I’m omitting the non-Astro code snippets to be concise. View the source code of my Navigation component to see the missing parts.

#Outcomes

I had a lot of fun doing this rewrite. Astro’s philosophy is really clicking for me; relying on native browser APIs and SSG first, and allowing optional SPA features or React/Vue/Svelte components where needed.

The technical outcomes of the rewrite were great too!

Disclaimer: I’m comparing an older version of Vue (Vue 2) to the newest version of Astro. I also upgraded NodeJS from 16 to 20 during this rewrite. Your results may vary.

#3x smaller JS bundle size

The Vue app had a JS bundle size of 135KB (23KB for my app and 112KB for vendors). The Astro app has a JS bundle size of 42KB. That’s 3x smaller!

#2x faster build time

The Vue app had an average build time of 18s. The Astro app has an average build time of 9s. That’s 2x faster!

#Conclusion

Astro is awesome for content-heavy websites with a bit of client-side functionality 🥳. I liked it so much, that I might rewrite my Jekyll blog to Astro too.

If you want to compare the full source of my app before and after the rewrite, see: