The Astro(nomical) Migration
Sometimes, HTML & CSS is all you need
Motivation
I wanted a new portfolio now that I’ve been learning web development for two years but I kind of liked the design of my old one. So, I decided to rebuild it from scratch and add improvements on top.
I should add that this is actually the third version out of four portfolios I’ve made.
-
The first version was to help me learn about web development instead of reading tutorials and watching YouTube videos about it.
- It is available here if interested
-
The second version was completed but never released. In short, I hated it after spending all that time developing it.
- It is available here if interested
-
The fourth version was in development shortly after the v3 Webpack version was released, but it will never see the light of day since I actually really like the current v3 version (and I hope you do too).
So, this is a rebuild of the v3 version. The original v3 version is available here.
I should also add that this is actually the second rebuild I’ve done of version 3. Version 1 was powered by Webpack, which was far too slow (Webpack that is) and version 2 involved migrating to Vite (as well as migrating the project to TypeScript).
This rebuild - the website you’re currently viewing - involved migrating to Astro.
Abandoning Vue
The JavaScript overhead introduced by Vue was simply too much for the mostly static content on the site. Hence, I made the executive decision to purge all traces of Vue from the rebuild and start from scratch.
Why Astro?
Now, I could have used any other framework supporting static site generation (SSG) e.g., Nuxt - a framework around Vue which would mean I would not have to change much code to do reduce client-side JS.
There isn’t so much as an in-depth analysis of said frameworks; I simply saw Astro on GitHub trending and decided to give it a go.
That being said, you should probably give Astro’s “Why Astro” a read if you’d like more formal arguments.
Making the Switch
Copying components which serve content only was quite simple - it was the dynamic components that required a rethink.
A New Cursor
The cursor had to be a dynamic, reactive element as there was no other way to keep it in sync with the real mouse. However, I decided against using an existing animation library.
You can read more about how I built it in my other blog post here.
Scroll to Top
Not much changed between the Vue and Astro version except I no longer use gsap
to animate the SVG elements - just CSS.
Now this did require more JS on my part (since I had to manually call updates to the elements), it is a lot smaller than the original Vue + gsap
setup I had before.
Text Input
The text input SFC I had written in Vue was reactive, however, the contact form makes a native POST
request to a form collection API making its reactivity useless. It also had functions to listen to focus events and apply attributes to put the input into the focused stated.
This was perhaps the biggest shock to me. The Astro component works in exactly the same way. Using CSS, I was able to mimic the focus (de-)activations.
The Vue text input also had functions to disallow certain keys. This was implemented using a string representing JavaScript code. Given an array of strings ignoreKeys
to disallow from the input:
const onKeyDown = `return !(${JSON.stringify(ignoreKeys)}.includes(event.key))`;
And this is used in the HTML template as:
<input onkeydown="{onKeyDown}" />
The stringification of the keys to ignore means the text input can be built during build-time since we know what keys to ignore ahead of time.
The button to delete the text in the input works in a similar way. Given the id
of the text <input />
element:
const onClick = `document.getElementById("${id}").value = "${value}"`;
And the associated delete button:
<button onclick="{onClick}">...</button>
And just like that, no JavaScript! Well, just a little.
However, I’m not sure if this is a good practice (probably not) …
Fonts
Fonts are now stored locally and bundled with the application instead of making a request to Google fonts.
Additionally, I created an Astro integration subfont
, which is a wrapper around glyphhanger
to subset the fonts (remove any unused characters in the font file) to minimise the bundle size.
The plugin also works for vite
, esbuild
, rollup
and webpack
so give it a try.
Unfortunately however, something within glyphhanger
causes it to hang when using the plugin and trying to deploy it on Vercel using the automatic GitHub integration.
I have no idea why it occurs and is probably something you have to raise with glyphhanger
. Instead, I use a GitHub action to:
- Install
bun
, dependencies and the Vercel CLI - Install
python
- Install python dependencies that
glyphhanger
requires - Build the application using the Vercel CLI
- Deploy the application using the Vercel CLI
Icons
Previously, I used Material Icons from the CDN. So, to render an icon, the Material Icons stylesheet had to be loaded first meaning an unstyled <span />
element would be shown while the stylesheet is loaded. This is even more of an issue on slow networks.
Now, the icons are built at compile time with the SVGs stored locally. This does require me to download the <svg />
HTML from a site like iconifybut this can easily be solved by using the astro-icon
library which fetches it for me (at build time) based on the icon name.
The only reason why I don’t use this library currently (I did early on in development) is because it didn’t work with server-side rendering - an approach I experimented with, but ultimately decided against in favour of SSG, again during development of this rebuild.
SEO
Astro makes it easier access the <head />
of the page because I can define the entire page dynamically. With an SPA, I’d only set common meta
tags in the index.html
page shell. With Vue, I’d have to use a separate plugin to achieve the same thing as Astro and would be a lot less elegant.
With Astro, I can define a HeadSEO
component which contains any SEO-related tag which can be controlled by component’s props:
---
export interface Props {
/**
* Page title
*/
title: string;
/**
* Page description for SEO
*/
description: string;
/**
* Fully-qualified URL of webpage
*/
canonicalURL: string | URL;
/**
* Page language
*/
lang: string;
site: {
title: string;
baseURL: string | URL;
};
image?: Image;
og?: {
title?: string;
type?: string;
description?: string;
locale?: string;
image?: Image;
};
twitter?: {
title?: string;
description?: string;
image?: Image;
/**
* Twitter handle
*/
username?: string;
};
}
interface Image {
src: string;
alt: string;
}
const props = Astro.props as Props;
const canonicalImgSrc = props.image?.src
? new URL(props.image.src, props.site.baseURL).toString()
: undefined;
const ogImage = props.og?.image?.alt ?? props.image?.alt;
const twitterImage = props.twitter?.image?.alt ?? props.image?.alt;
---
<>
<!-- Primary Meta Tags -->
<meta name="description" content={props.description} />
<link rel="canonical" href={props.canonicalURL.toString()} />
<meta name="apple-mobile-web-app-title" content={props.site.title} />
<!-- OpenGraph / Facebook -->
<meta property="og:title" content={props.og?.title ?? props.title} />
<meta property="og:type" content={props.og?.type ?? "website"} />
<meta property="og:locale" content={props.lang} />
<meta property="og:url" content={props.canonicalURL.toString()} />
<meta
property="og:description"
content={props.og?.description ?? props.description}
/>
{canonicalImgSrc && <meta property="og:image" content={canonicalImgSrc} />}
{ogImage && <meta property="og:image:alt" content={ogImage} />}
<meta property="og:site_name" content={props.site.title} />
<!-- Twitter -->
<meta
property="twitter:title"
content={props.twitter?.title ?? props.title}
/>
<meta
property="twitter:description"
content={props.twitter?.description ?? props.description}
/>
<meta property="twitter:url" content={props.canonicalURL.toString()} />
<meta name="twitter:card" content="summary_large_image" />
</>
{
props.twitter?.username && (
<>
<meta name="twitter:site" content={props.twitter.username} />
<meta name="twitter:creator" content={props.twitter.username} />
</>
)
}
{canonicalImgSrc && <meta name="twitter:image" content={canonicalImgSrc} />}
{twitterImage && <meta name="twitter:image:alt" content={twitterImage} />}
And just like that, SEO-improving tags determined by the current page.
Results
Now, I could have simply added on the features I wanted in Vue if the migration to Astro made little difference. Fortunately, I did see large improvements.
Build Size
Comparing final build sizes (minified and gzipped), the original version:
That is: - HTML: - CSS: - JS:
Which gives a total size of 214.9 kB.
And the rebuild version:
That is: - HTML: - CSS: - JS:
Which gives a total size of 35.7 kB.
So, using Astro has saved us 179.2 kB (143%)!
Now, this comparison isn’t entirely fair since the rebuild does contain more CSS. If I had used the same stylesheet, the bundle size would be even smaller!
I should add that this does not make much of a difference for someone like me whose internet is fast enough. It also does not make much of a difference on this site since it does not have much in the way of content.
However, if this website was larger, or more dynamic, the improvements would be much more pronounced.
Performance Improvements
In the last section, we saw a massive reduction in bundle size which alone should provide enough of a performance improvement. I’d also like to note the reduction in network requests (due to bundling a single CSS & JS file) from 13 to 8.
I opted to disable CSS code splitting and just bundle one stylesheet. I should add that this wasn’t for performance reasons at first.
Any page transition library I used would not touch anything within the <head />
tag meaning new stylesheets were not loaded and the meta tags were not updated.
So, I use a single stylesheet to avoid this which has the added benefit of reducing the number of network requests made. As for the meta tags, I do this manually for each page transition just changing the title, description and URL meta tags.
Comparing the times (the bottom right in each image above):
-
Load
- from 163ms to 109ms- The time taken for the entire page to be loaded, including any resources on the page.
-
DOMContentLoaded
- from 124ms to 104ms- This is the time taken for the entire HTML page to be completely parsed without waiting for any resources
So, a small improvement here.
Finally, comparing the lighthouse scores (on mobile), we have the following:
Before:
After:
So:
- A 0.9s improvement in “First Contentful Paint” from 1.8s to 0.9s
- A 0.9s improvement in “Time to Interactive” from 1.8s to 0.9s
- A 1.8s improvement in “Speed Index” from 2.9s to 1.1s
- A 0.7s improvement in “Largest Contentful Paint” from 1.8s to 1.1s
I could test more pages, but it is the exact same story elsewhere.
All in all, the migration to Astro was well worth it.
New Features
- More advanced page transition animations
- Sitemaps, Blog posts and RSS feeds!
- Fetch git commit date for a blog post to determine when it was last updated
- System-based dark mode!
- Works with JavaScript disabled
- Except cursor, page transitions and the delete button for text inputs
I guess the styling is better, but this is not because of Astro - probably because of the two-year time difference between the original and rebuild versions.
Conclusion
I learned a lot of invaluable information about performance optimisation, SEO and accessibility through this migration task.
To summarise:
Accessibility
- Do not disable focus borders - style them to your liking instead
- Label all images with an
alt
tag for screen readers - Ensure heading elements are in a hierarchical order (
h1 > h2 > h3 > ...
)
Static Asset Handling
-
Compress all assets and use next-gen web formats (like WebP for images and woff2 for fonts)
-
Store your assets locally, including icons and fonts
- Subset your fonts!
-
Inline critical CSS and defer the rest
- Also consider bundling a single CSS file, if it isn’t too large, to reduce the number of network requests made
-
Use preload hints to control the loading priority of each
<link />
ed asset -
Minify all CSS, HTML and JS
- Your bundler should minify JS at least, plugins are available to handle HTML & CSS.
-
Compress everything with
gzip
orbrotli
if your hosting provider does not already do so- I can almost guarantee that they do
Handling SEO
- Generate files necessary for crawling (like
robots.txt
and a sitemap) - Generate an
rss.xml
to help keep users up to date with your blog posts
JavaScript
-
Try implementing things in standard HTML and CSS before reaching for JavaScript
-
Minimise the amount of JavaScript send to the client
- Astro’s opt-in approach to including JavaScript makes this much easier
-
Reactivity is not always needed, especially for static sites like mine
Closing Remark
Pure HTML & CSS can go a long way in speeding up your website. For static sites, Astro is the undoubtedly way to go.
Hope you enjoy the site!