Just for fun, I rewrote my blog in Astro last weekend. Mostly I wanted to be able to use MDX to embed custom components into my posts.
I previously used Hugo, which I would still recommend in many cases.
Everything should look the same as before, more or less. There are some slight differences, but overall I’m satisfied.
Content changes
Both Hugo and Astro support Markdown, so the only real content changes I needed were to replace all the shortcodes.
Shortcodes are a Hugo-specific syntax that look like {{< foo >}}
and are used for relative links, figures, and other things that aren’t part of Markdown itself. They have both inline and block variants, accept attributes, and generally feel like HTML-lite, with extra moustaches.
Links
In Hugo, I usually wrote links to other blog posts using the {{< ref >}}
shortcode:
[Some other post]({{< ref "/posts/some-other-post.md" >}})
With Astro, these all have to be replaced by plain paths:
[Some other post](/posts/some-other-post/)
The Astro version is more readable, but Hugo wins on URL management — the ref
shortcode is automatically determining the URL based on the filename, permalink rules, etc., while the Astro link is just a link with no magic.
Figures
Figures were another common use for Hugo shortcodes. I wrote figures like this:
{{< figure
src="/images/homelab/wip1.jpg"
link="/images/homelab/wip1.jpg"
caption="Caption text"
alt="Alt text"
>}}
With Astro, I updated all the figures to use a custom <Figure>
component. Now they’re written like this (and yes, you can put import
statements right in your MDX):
import wip1 from '@assets/homelab/wip1.jpg';
<Figure src={wip1} alt="Alt text">
Caption text
</Figure>
In this case, Astro wins on URL management — assets are imported by local filename, optimized, and the URL is determined automatically. Whereas the Hugo version is just a wrapper around the <figure>
HTML element with no magic.
Fancy stuff
I created aftermath, an HTML post-processor, to extend Hugo with some fancy stuff:
How does this work with Astro? Well, here Astro shines — there are existing integrations for Mermaid (astro-diagrams) and KaTeX (as a Remark plugin) which do server-side rendering out of the box. And because Astro exposes the full Node runtime API when authoring components, I can shell out to the d2
CLI from within a custom <D2>
component, something impossible with Hugo templates or shortcodes.
The Mermaid component is used like this:
<Mermaid>
graph TD;
foo-->bar;
</Mermaid>
And renders to this:
The formatting works pretty much the same it always has. For example:
$$
c = \sqrt{a^2 + b^2}
$$
Will render as:
One limitation with the Astro version: the \\[ ... \\]
and \\( ... \\)
syntaxes seem to crash whatever MDX document they’re in. This is probably fixable, but I’ve just constrained myself to using $$ ... $$
and $ ... $
, which I prefer anyway.
Finally, a <D2>
component:
<D2 code="foo -> bar" />
Which renders like this:
Utility blocks
The other changes were to the blocks of HTML I use for some markup.
“Note” blocks, for example, were written directly into the Markdown:
<div class="note">
This is a note!
</div>
And rendered like:
This is a note!
In Astro, I’ve defined utility components for these:
<Note>
This is a note!
</Note>
New stuff
Islands
Astro uses an islands architecture, which means pages are generally rendered 100% server-side and not hydrated on the client. If you want client-side interactivity, you can partially hydrate just the sections of the page that need it, creating islands of interactivity in a sea of plain HTML.
Astro lets you mix and match islands with different view frameworks (React, Svelte, etc.) in the same site, even the same page. It automatically detects the view framework in use based on what’s imported.
React
Here’s a React island. Try clicking the buttons:
It’s interactive! This is what that looks like in the MDX:
import DemoReact from "@components/DemoReact.jsx"
<DemoReact client:load />
The client:load
Astro directive is what makes this an island.
Here’s the code from DemoReact.jsx
:
import * as React from 'react';
export default () => {
const [ counter, setCounter ] = React.useState(0);
return <div>
<button onClick={() => setCounter(counter - 1)}>-</button>
<span style={{'margin': '0 10px'}}>{counter}</span>
<button onClick={() => setCounter(counter + 1)}>+</button>
</div>
}
Solid
Of course, we’re not limited to just React! Here’s a Solid island:
The MDX for this:
import DemoSolid from "@components/DemoSolid.jsx"
<DemoSolid client:load />
And the code from DemoSolid.jsx
:
import { createSignal } from 'solid-js';
export default () => {
const [counter, setCounter] = createSignal(0);
return <div>
<button onClick={() => setCounter((v) => v - 1)}>-</button>
<span style={{'margin': '0 10px'}}>{counter()}</span>
<button onClick={() => setCounter((v) => v + 1)}>+</button>
</div>
}
SSG Components
If client-side interactivity isn’t important, you can still use view frameworks (React, Svelte, etc.) to author server-side-only components. Just omit the client:load
directive:
<DemoReact />
What you get is the same HTML, but without partial hydration:
This is cool when you want to use a view framework but don’t need interactivity.
Charts
Check this out — I’ve been wanting to mess with Victory in my blog posts, and now I can!
Here’s an example of a chart from the Victory gallery. It’s rendered from React to SVG on the server-side:
Build times
I always knew Hugo was fast, but switching to Astro put that into a whole new perspective.
Comparing from-scratch production builds with 0, 15, and 30 posts:
I’m sure there are things I can do to make my Astro build faster, but Hugo is crazy fast.
Luckily I’m not a very prolific blogger; slow builds don’t matter very much when you have sub-100 posts!
For non-production use, both Hugo and Astro use a server that detects changes and rebuilds only the changed page(s). These incremental builds are very fast with both frameworks: Astro’s tend to be ~100ms for me, depending on the complexity of the page, and Hugo’s are, of course, nearly instant.