How I made my blog with SolidStart and MDX

andi23rosca

Andi Rosca

Posted on November 21, 2024

How I made my blog with SolidStart and MDX

Blog here: https://andi.dev/blog/how-solid-start-blog


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 assume you're already familiar with how SolidStart works, especially the file-based router.

If you're not, the official docs do a better job at explaining it than I could.

List all posts programmatically

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 plugin

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

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();
        }
      });
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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

export default defineConfig({
  ...
    vite: {
        plugins: [
            ...other plugins
            blogPostsPlugin(), // Add it here
        ],
    },
  ...
});
Enter fullscreen mode Exit fullscreen mode

Processing the files

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");
};
Enter fullscreen mode Exit fullscreen mode

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

Folder structure

Then the json file will look like

[
  { "slug": "post-1" },
  { "slug": "post-2" },
  { "slug": "post-3" }
]
Enter fullscreen mode Exit fullscreen mode

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

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

Using the posts list

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

<For each={posts}>
  {post => <a href={`/blog/${post.slug}`}>{post.slug}<a/>}
</For>
Enter fullscreen mode Exit fullscreen mode

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 content

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/>
Enter fullscreen mode Exit fullscreen mode

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

<a href={`/blog/${post.slug}`}>{post.title}<a/>
Enter fullscreen mode Exit fullscreen mode

Parsing the frontmatter

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");
};
Enter fullscreen mode Exit fullscreen mode

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

---
title: This is my first post!
date: 2024-09-04
tags:
  - solidjs
  - solid-start
---
Enter fullscreen mode Exit fullscreen mode

Then the metadata json will become:

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

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/>
Enter fullscreen mode Exit fullscreen mode

Important info:

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

export default defineConfig({
  ...
    vite: {
        plugins: [
            mdx.withImports({})({
                remarkPlugins: [remarkFrontmatter], // Add it here
            }),
        ],
    },
  ...
});
Enter fullscreen mode Exit fullscreen mode

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 highlighting

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:

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;
    }
};

Enter fullscreen mode Exit fullscreen mode

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

export default defineConfig({
  ...
    vite: {
        plugins: [
            mdx.withImports({})({
                rehypePlugins: [mdxPrism], // Add it here
            }),
        ],
    },
  ...
});
Enter fullscreen mode Exit fullscreen mode

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 example

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.

💖 💪 🙅 🚩
andi23rosca
Andi Rosca

Posted on November 21, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related