Skip to main content
~/andi.dev
SolidJS banner

How I made this blog with SolidStart and MDX

4 September 2024

SolidStart is to SolidJs what NextJs is to React.

It's a general "full-stack" framework that lets you SSR, CSR, SSG and all the other acronyms.

It comes with docs for SSG and a with-mdx starter template that lets you get started quickly with a markdown-powered static website.

So why am I writing this then?

Because a tech blog has a few extra requirements that are not supported by default and I had to figure out myself. Hopefully it saves other people some time.

>

I'm not good at keeping things short so look through the contents if you're only interested in one particular thing

Contentslink

List all posts programmaticallylink

The FileRoutes router is great. But it doesn't expose any of the information that it has on its routes.

Most (if not all) blogs have some kind of list of latest posts on the homepage, or an archive somewhere. Ideally, we would generate that automatically, based on the files in the posts directory.

Now, I only have 5 posts in total on here. I could very well just keep a manual list of posts in code somewhere and update it when I add a new post file.

But I'd rather spend a day figuring out how to automate it, than 5 minutes doing it manually.

The most painless way I found to set this up is using a custom vite plugin.

The vite pluginlink

A vite plugin is just an object that conforms to the vite plugin api.

blogPostsPlugin.ts
import type { Plugin } from "vite";

export const blogPostsPlugin = (): Plugin => {
  return {
    name: "blog-posts-gen",
    async buildStart() {
      processFiles();
    },
    configureServer(server) {
      server.watcher.on("change", (filePath) => {
        if (filePath.includes("/src/routes/blog")) {
          processFiles();
        }
      });
    },
  };
};

This is the whole plugin.

It does 2 things:

  1. Calls processFiles when the build process starts (this is when you're building the website for prod)
  2. It hooks into vite's dev server listener, and calls processFiles when any file in the /src/routes/blog directory has changed.

Make sure to add it to your app config

app.config.ts
export default defineConfig({
  ...
	vite: {
		plugins: [
            ...other plugins
			blogPostsPlugin(), // Add it here
		],
	},
  ...
});

Processing the fileslink

Your processFiles function could look very different from mine, but here's what mine does:

import { resolve, join } from "node:path";
import { readdirSync, statSync, writeFileSync } from "node:fs";

const processFiles = () => {
	const outputFile = resolve("src/data/posts.json");
	const blogDir = resolve("src/routes/blog");
	const files = readdirSync(blogDir);

	const blogPosts = files
		.filter((file) => 
        statSync(join(blogDir, file)).isFile() && file.endsWith(".mdx")
      )
		.map((file) => ({slug: file.replace(".mdx", "")}));
    
	writeFileSync(outputFile, JSON.stringify(blogPosts, null, 2), "utf-8");
};

It gets all files ending with .mdx in the blog directory, and maps them to a json list of type { slug: string}[].

Then writes that list out to src/data/posts.json.

If the folder structure looks like

  • /andi.dev
    • src
      • routes
        • blog
          • post-1.mdx
          • post-2.mdx
          • post-3.mdx
      • (home).tsx

Then the json file will look like

posts.json
[
  { "slug": "post-1" },
  { "slug": "post-2" },
  { "slug": "post-3" }
]

I'm using typescript so I have an extra file that imports the JSON and adds types to it:

posts.ts
import JSONPosts from "./posts.json";
type Post = { slug: string };
export const posts: Post[] = JSONPosts;

Using the posts listlink

Now that it's available as a module export you can import it and use it anywhere in your solid component:

PostsList.tsx
<For each={posts}>
  {post => <a href={`/blog/${post.slug}`}>{post.slug}<a/>}
</For>

The best part of this (to me), is that the json file gets updated whenever there's a change in the blog directory.

That will trigger vite's HMR and automatically refresh any modules depending on it while you're developing locally.

Keep a post's metadata in the same file as the contentlink

Posts do not only have their content. They usually also have some metadata associated with them.

The ones I wanted to support were:

  • Title
  • Publishing date
  • List of tags

I'm picky about co-locating things under the same domain.

In this case, I wanted to keep the metadata for a post in the same mdx file as where the content is.

The usual way of adding metadata to a markdown file is using the frontmatter.

I also wanted to use this metadata from my dynamic posts list.

You might've noticed the component above was using the slug to render the post links:

<a href={`/blog/${post.slug}`}>{post.slug}<a/>

But what should actually happen is using the post's title instead

<a href={`/blog/${post.slug}`}>{post.title}<a/>

Parsing the frontmatterlink

I ended up modifying the processFiles function to also parse the file frontmatter, using to-vfile and vfile-matter together.

const processFiles = () => {
	const outputFile = resolve("src/data/posts.json");
	const blogDir = resolve("src/routes/blog");
	const files = readdirSync(blogDir);

	const blogPosts = files
		.filter((file) => 
        statSync(join(blogDir, file)).isFile() && file.endsWith(".mdx")
      )
		.map((file) => {

      // Turn each of the post files into vfiles
			const vfile = readSync(resolve("src/routes/blog", file));
      // Parse their frontmatter
			matter(vfile);

      return { 
        // Add the frontmatter properties to each post's metadata
				...(f.data.matter as object),
        slug: file.replace(".mdx", "")
      }
    });
    
	writeFileSync(outputFile, JSON.stringify(blogPosts, null, 2), "utf-8");
};

I write the frontmatter in yaml, so when I add the following to the top of a post:

post-1.mdx
---
title: This is my first post!
date: 2024-09-04
tags:
  - solidjs
  - solid-start
---

Then the metadata json will become:

{
  "slug": "post-1",
  "title": "This is my first post!",
  "date": "2024-09-04",
  "tags": ["solidjs", "solid-start"]
}

I can then use that metadata whenever I want to, and especially when rendering lists of posts:

<a href={`/blog/${post.slug}`}>{post.title}<a/>

Important info:

You should also install remark-frontmatter and add it to the remarkPlugins in app.config

app.config.ts
export default defineConfig({
  ...
	vite: {
		plugins: [
			mdx.withImports({})({
				remarkPlugins: [remarkFrontmatter], // Add it here
			}),
		],
	},
  ...
});

The reason for that is that you want the frontmatter to be excluded when the solidjs mdx pipeline transforms the mdx file into static html.

If you don't do this, you'll see the frontmatter rendered as html when you navigate to a post's page.


Compile-time code highlightinglink

The grumpy engineers on HackerNews got to me. I wanted to support code highlighting even if someone has javascript disabled.

That means moving the highlighting process from running on the client (the browser, using js), to it running when the static HTML is being generated.

I'm using refractor for that. It's a wrapper around prismjs that lets you do highlighting on virtual files.

To hook it into the solid-mdx building process, I had to create my own custom rehype plugin:

mdxPrism.ts
import { visit } from "unist-util-visit";
import { toString as nodeToString } from "hast-util-to-string";
import { refractor } from "refractor";
import tsx from "refractor/lang/tsx.js";

refractor.register(tsx);

export const mdxPrism = () => {
	return (tree: any) => {
		visit(tree, "element" as any, visitor);
	};

	function visitor(node: any, index: number | undefined, parent: any) {
		if (parent.type !== "mdxJsxFlowElement") {
			return;
		}

		const attrs = parent.attributes.reduce((a: any, c: any) => {
			if (c.type === "mdxJsxAttribute") {
				a[c.name] = c.value;
			}
			return a;
		}, {});

		const lang = attrs.lang;
		if (!lang) {
			return;
		}

		const result = refractor.highlight(nodeToString(node), lang);
		node.children = result.children;
	}
};

I won't go through the details of what it does exactly. The gist of it is that it finds the code blocks from the parsed markdown and uses refractor on them.

It needs to be added to the app config as well, under rehypePlugins

app.config.ts
export default defineConfig({
  ...
	vite: {
		plugins: [
			mdx.withImports({})({
				rehypePlugins: [mdxPrism], // Add it here
			}),
		],
	},
  ...
});

Refractor generates the same class names as prism, so as long as you have a prism theme css file loaded, it'll show some nice highlighting.

Full code examplelink

I'm keeping the code for this website in a public github repo.

I tried to keep this article small, so if I missed anything, feel free look over the full working implementation in there.

Design inspired by (and copied from): owickstrom.github.io/the-monospace-web