Building a Blog like it’s 2022 ✨
This is a post about how I built the first version of this site. You can check out the source code on GitHub.
What was I looking for in a blog? Three things. It should be easy to:
- draft blog posts in a familiar language (markdown)
- put everything into version control (git)
- most importantly, incorporate rust 🦀 components for interactive demos
Yes, that orange crab string actually comes from rust compiled to WebAssembly! As another example, here’s a backed by Rust that increments every time you click it.
This setup will help illustrate the concepts I’ll be covering in future posts, such as an interactive bevy physics sim. I’ll walk through some of the highlights of getting this setup so you too can have a rusty blog!
This post details some bits and pieces of how I got this setup working, but for the full version just head over to the GitHub repo!
If you’ve done your fair share of Next.js / MDX, you might want to just skip to the rust part of the post right now :)
Next.js + React + Typescript = 💖
While I’m excited about some of the efforts going into rust frontend dev, I don’t think anything beats the productivity of React + Typescript just yet. Don’t worry, we’ll get to the rust later!
The first step is to start with a Next.js Typescript project.
yarn create next-app --typescript
Next I recommend setting up TailwindCSS. I have personally found it to be a joy to work with, particularly when tweaking styling constantly while things are in flux. Make sure you add *.mdx
to the pages
section of tailwind.config.js
so that classes that are only used in MDX aren’t thrown away in a production build.
And… that’s pretty much it! Most of you have probably used Next.js before but for those that are new, just run yarn dev
and go to http://localhost:3000 to see a live preview of your site.
MDX
Speed of writing blog posts is very important to me. I also knew I wanted to keep everything in source control and use a familiar syntax like Markdown. This led me to pick MDX for writing actual blog posts.
The main components I needed were
- code blocks with syntax highlighting
- embeddable interactive react components
- an index page that lists each of the existing posts
With the following config changes, Next.js will compile any .mdx
file as an independent page:
// next.config.js
const rehypePrism = require("@mapbox/rehype-prism");
const withMDX = require("@next/mdx")({
extension: /\.mdx?$/,
options: {
remarkPlugins: [],
rehypePlugins: [rehypePrism],
providerImportSource: "@mdx-js/react",
},
});
module.exports = withMDX({
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
reactStrictMode: true,
};
I also added in rehype-prism
for code syntax highlighting.
Blog Index
No blog is complete without a page that lists all of the blog posts in chronological order. I settled on a blog.tsx
page that uses getStaticProps
at build time to iterate through all of the .mdx
files in the blog
folder. Each post export
s an object called meta
that is shaped like
type Meta = {
date: string;
title: string;
subtitle: string;
draft?: boolean;
};
The import(..)
statement in blog.tsx
compiles the mdx file into a javascript module that exposes this meta
object. We can then sort the posts by descending date and format them into clickable rows just like we would for any other list data rendered in React.
return (
<div>
{props.blogs
.sort((a, b) => (a.meta.date < b.meta.date ? 1 : -1))
.filter(
(blog) => !blog.meta.draft || process.env.NODE_ENV == "development"
)
.map((blog) => (
<div key={blog.path}>... some bloggy dom elements ...</div>
))}
</div>
);
This structure made it really simple to add the draft
meta field to hide blog posts that aren’t ready for public consumption but that I want to see in my local dev environment. Yes I know they are visible in the public repo, think of it as learning in public 😁
Styling
MDX by default leaves all of the markdown elements unstyled. However, each type of markdown element is rendered as the analogous HTML element. For example, #
maps to h1
, ##
to h2
, and -
to li
. One way to style these elements is by writing css for each of the corresponding dom elements. However, I am very bought-in to using Tailwind. I also needed to set more than just styles, for example prepending a -
before each list item.
In addition to css, MDX lets you specify a React component for each dom element rendered from the markdown. These dom element-to-component mappings are passed into an MDXProvider
, concentrating all of the blog styling and functionality into the bloglayout.tsx
file.
This let me keep using Tailwind classes (yay!). It was also super easy to implement deep-links to h1
and h2
elements by
- wrapping the header in a link with an href
set to the header text
- setting the id
of the header to that same text
export const components: Components = {
h1: ({ children }) => (
<a href={`#${children?.toString()}`} className="text-inherit">
<h1 className="text-4xl py-4" id={children?.toString()}>
{children}
</h1>
</a>
),
...
};
Yes my XSS-spidey-senses are tingling at the above, but hey we’re in 2022 so as long as there is no dangerouslySetInnerHTML
we’re in the clear 😅
Rust
And now for the part we’ve all been waiting for… let’s add some Rust! The high-level idea is to create a rust library crate that is compiled to WebAssembly using the wasm-pack
CLI. That WebAssembly can then be imported into a .tsx
component or directly used in a .mdx
file.
Our first step is to install wasm-pack
. There is an installer, but I was able to compile from source on windows using cargo install wasm-pack
(after installing perl
and openssl
with scoop
).
Creating the Rust Crate
wasm-pack
makes the rest of the rust side of things pretty painless. Run wasm-pack new rust
to generate a complete cargo project in the rust
folder. There are just a couple of tweaks we should make to the generated project:
- since we want this folder to be part of the parent repository, remove the .git
folder in the generated crate: rm -rf rust/.git
- rm
all of the CI stuff for now, like Travis and Appveyor
- Change the edition
to 2021
in Cargo.toml
- Remove the optional
field in the wee_alloc
dependency in Cargo.toml
- Remove the #[cfg(..)]
in lib.rs
over the ALLOC
line. We always want to use wee_alloc
so we can minimize our binary size.
Great! Our wasm crate now exists. Run wasm-pack
and make sure it builds successfully. The build output should be in the pkg
folder of your rust crate.
The default template calls alert()
through javascript which will open a dialog window (useful for debugging, but so mean to our poor blog readers!). Instead, let’s add a function to lib.rs
to return a String
.
// lib.rs
#[wasm_bindgen]
pub fn rust_string() -> String {
"rust 🦀".into()
}
Adding
pub
and#[wasm_bindgen]
will make this function callable from anything that imports this wasm module.
Note that we will have to run wasm-pack build
every time we change any rust code. I recommend using cargo-watch
or some other tool to automatically recompile the wasm whenever something changes. It’s also a good idea to add wasm-pack build
to the build
script in your package.json
file.
Calling from MDX
Using our wasm module is now as simple as importing it into an MDX file:
// blog_post.mdx
import { rust_string } from "../../rust/pkg";
a rust string {rust_string()}
That’s it! We just need a few changes to our Next.js config to support wasm, mainly a workaround for an issue with compiled wasm blobs being written to the wrong directory:
// next.config.js
...
/** @type {import('next').NextConfig} */
module.exports = withMDX({
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
reactStrictMode: true,
webpack: function (config, options) {
// https://github.com/vercel/next.js/issues/29362#issuecomment-932767530
config.output.webassemblyModuleFilename =
options.isServer && !options.dev
? "../static/wasm/[id].wasm"
: "static/wasm/[id].wasm";
config.optimization.moduleIds = "named";
config.experiments = { asyncWebAssembly: true, ...config.experiments };
return config;
},
});
And with that, we’re ready to deploy our blog to production! 🚢 it!
Custom Vercel Build
Up until now, everything we’ve done is independent of where you choose to deploy your Next.js project. I personally chose to use Vercel as it’s the easiest way I know to deploy anything Next.js. While Vercel usually requires zero configuration, to get rust + wasm working I had to make a few changes to the build process.
Vercel recommends installing rust using amazon-linux-extras install rust1
. However, this installs an older version of rust that doesn’t support the 2021 edition. I installed rust using rustup
instead.
Next, the simplest way to install wasm-pack
would be to compile it with cargo install
. However this takes over a minute on the Vercel build vm. Instead, I added a script to download the wasm-pack
musl linux binary from GitHub.
Both of these steps are captured in a bash script:
# vercel-install.sh
#!/bin/bash
set -x
set -e
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | bash -s -- -y
export PATH="/vercel/.cargo/bin:$PATH"
curl "https://github.com/rustwasm/wasm-pack/releases/download/v0.10.2/wasm-pack-v0.10.2-x86_64-unknown-linux-musl.tar.gz" -o wasm-pack.tar.gz -s -L
tar xvf wasm-pack.tar.gz --wildcards --no-anchored 'wasm-pack' --strip-components=1
rm wasm-pack.tar.gz
chmod +x wasm-pack
mv wasm-pack /usr/bin
yarn install
To make Vercel run this bash script before your build, set the INSTALL COMMAND
in your Vercel deployment settings to bash ./vercel-install.sh
. Finally, set the BUILD COMMAND
to yarn build
so that wasm-pack build
is run before next build
. I hope Vercel considers adding rust + wasm-pack to a pre-baked build image to make this whole process a bit easier!
Thanks for reading! I hope this post helped smooth out a few of the rough edges with getting rust integrated into your own blog. Feel free to clone, fork, or pull request this site on GitHub!