While I was hoping this website would build itself, it ended up taking a good amount of fine-tuning! I wanted to share how I built this blog and a few of the challenges I encountered.
Background
My setup for this website is:
- domain and DNS managed on GoDaddy
- code lives in a private GitHub repository (should I make this public?)
- static site written in React using Gatsby
- site deployed using the free tier on Netlify
While Dev.to and Hashnode have been helpful to get me flowing on my technical writing, I really wanted a minimal blog over which I exercised full control. I wanted to be able style blockquotes the way I liked. I wanted the blog to reflect the From Scratch ethos that less is better.
The final straw: when I realized I was missing out on potential SEO improvements by not publishing my writing directly to fromscratchcode.com
.
Thus this silly blog that I’m moderately proud of was born!
List of Requirements
My requirements for the new blog were:
- Posts shall be written in Markdown and be portable, meaning require no changes to be thrown into other editors (Dev.to, Hashnode, etc). As a bonus, I currently write these in Notion because they copy out already in Markdown.
- Posts shall support syntax highlighting for code blocks that actually look good. (This was a PAIN on my email provider. too bad this post isn’t about picking an email provider!)
- Internal links within posts shall be optimized for navigation and SEO.
Gatsby is modern, flexible, and I’d barely used it before, which gave me the confidence I could pull this off. I wrote this post because these requirements got more difficult as I went and I hope this can be a resource for someone else. Here’s what I learned!
Supporting Markdown Posts
My motivation for writing in Markdown will not surprise you: I wanted to spend more time writing and less time formatting. To move a piece (including code!) between platforms and have it just work
. Markdown checks all of those boxes.
Gatsby’s ecosystem is rich with markdown support. Here’s how I leveraged it!
I used the gatsby-transformer-remark
plugin to read the Markdown files. Next, I used the createPages
API in gatsby-node.js
to register a new page with slug (this is a URL that for some reason is named after an insect).
Then, I used Gatsby’s createNodeField
to attach additional metadata to the Markdown node. This ensures this metadata is available to components via GraphQL queries. I also used the reading-time
library to calculate the reading time for each block of Markdown to give each post some fun deets and make this look like a real blog.
Here are these pieces assembled together in my gatsby-node.js
file:
const BLOG_QUERY = `
{
allMarkdownRemark(
filter: { fileAbsolutePath: { regex: "/blog/" } }
sort: { frontmatter: { date: DESC } }
) {
edges {
node {
frontmatter {
slug
}
}
}
}
}
`
// This function runs a GraphQL query and creates pages at `${base_url}/${node.frontmatter.slug}`
// for each using the provided template.
// NOTE: each markdown file must provide its own slug in the frontmatter.
// TODO: could we provide a default in the case the frontmatter is not specified?
const createMarkdownPages = async (
graphql,
createPage,
query,
base_url,
template
) => {
const markdownFiles = await graphql(query)
markdownFiles.data.allMarkdownRemark.edges.forEach(({ node }) => {
const slug = `${base_url}/${node.frontmatter.slug}`
createPage({
path: slug,
component: template,
// The context is needed for the $slug param lookup in the query inside the
// repsective template
context: {
slug,
},
})
})
}
exports.createPages = async ({ graphql, actions }) => {
const { createPage } = actions
const blogPostTemplate = path.resolve(`src/templates/blogPostTemplate.js`)
await createMarkdownPages(
graphql,
createPage,
BLOG_QUERY,
"/blog",
blogPostTemplate
)
}
exports.onCreateNode = ({ node, actions, getNode }) => {
const { createNodeField } = actions
if (node.internal.type === "MarkdownRemark") {
let slug = node.frontmatter.slug
// Determine the base path (e.g., 'policies' or 'blog')
// This is the name from the gatsby-source-filesystem config in gatsby-config.js
const sourceInstanceName = getNode(node.parent).sourceInstanceName
// Add the slug field to the node, which will become queryable from GraphQL. This is safer
// than relying on frontmatter within the component pages because we have applied all the
// necessary transformations here before we write to the field.
createNodeField({
node,
name: "slug",
value: `/${sourceInstanceName}/${slug}`,
})
// Calculate the reading time of the markdown content and add it to the GraphQL
const stats = readingTime(node.rawMarkdownBody)
createNodeField({
node,
name: "readingTime",
value: stats.text, // Example: "3 min read"
})
}
}
I trimmed for brevity (HA!), but I process my Terms of Use and Privacy Policy using the same procedure. While those would not need syntax highlighting, requirement #3 about optimized internal links would still apply.
Next, I used Gatsby’s GraphQL to query for the post content based on the slug of the rendered page.
This is my first pass at blogPostTemplate.js
. I have stripped out a few pieces to focus on the GraphQL query.
import React from "react"
import { graphql } from "gatsby"
const BlogPost = ({ data }) => {
const post = data.markdownRemark
return (
<Layout>
<article>
<h1>{post.frontmatter.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</Layout>
)
}
export const query = graphql`
query ($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
frontmatter {
title
date(formatString: "MMM DD, YYYY")
}
fields {
readingTime
}
html
}
}
`
export default BlogPost
Consider the following piece of frontmatter, the metadata at the top of a Markdown file:
---
title: "Introducing: From Scratch Code"
date: "2024-11-04"
slug: "introducing-from-scratch-code"
---
At this point, we have created and populated a page at /blog/introducing-from-scratch-code/
. Awesome!
Enabling Syntax Highlighting
Syntax highlighting ended up taking two tries, both using PrismJS.
First Attempt: gatsby-remark-prismjs
My first approach used the gatsby-remark-prismjs
plugin (yes, this is a plugin [prismjs] to a plugin [remark] to a framework [gatsby]) during the Gatsby build pipeline. Here was the addition to my gatsby-config.js
file.
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [
{
resolve: `gatsby-remark-prismjs`,
options: {
classPrefix: "language-", // Set a class prefix
inlineCodeMarker: null, // Marker for inline code
// Do not use prism to highlight inline code
noInlineHighlight: true,
},
},
],
}
}
This approach does these steps behind the scenes:
- Read markdown
- Convert it to HTML
- Apply syntax highlighting via CSS classes
- Let React render our HTML which we fetched by querying GraphQL
This worked out of the box!
To customize the theme, I added this to my gatsby-browser.js
.
// Prism.js syntax highlighting
import "prismjs/themes/prism-tomorrow.css"
I had some trouble styling line numbers and the Copy-to-Clipboard button. There seemed to be some complexity around PrismJS plugins inside a static site processing pipeline such as Remark in Gatsby, so I punted on those.
This approached worked great until it didn’t, which brings me to requirement #3.
Optimizing Internal Links
When I say “internal link”, I mean a link on this website, such as /blog
.
I had two motivations for optimizing these:
- This is mostly me being particular, but in order to truly make my Markdown portable, I wanted to be able to write external links (such as
https://fromscratchcode.com/mentorship
) in Markdown and have it be treated as the internal link/mentorship
. - This is the biggie: Gatsby applies a magic touch using its
Link
component, which consists of several things.- Under the hood, Gatsby prefetches any
Link
destination when the page loads. - When clicked, it uses
@reach/router
to navigate to it using ultra-smooth client-side navigation. - This is all while appearing to be an
<a>
component in the page source, which keeps these links *chef's kiss* perfect for SEO because they remain visible to crawlers such as Google’s. - In addition, our React state persists across clicks. Without this optimization, the state of the dark mode toggle would persist when using the top nav, but not when clicking a link discovered in a blog post. That is not ideal!
- Under the hood, Gatsby prefetches any
Second Attempt: Switching to Rehype
The challenge here is turning [mentorship](/mentorship)
into <Link to="/mentorship">mentorship</Link>
and have it still be rendered as React.
There is a strong ecosystem of JS libraries to help with this, but I needed to switch from using PrismJS in a Remark plugin to a Rehype plugin. That sentence would have been word salad to me a few hours weeks ago, so please bear with me. Gatsby’s Remark pipeline converts Markdown to HTML before React sees it, so internal links get set before React can modify them. By using Rehype, we get a hook where we can dynamically swap <a>
for Link
. Rehype also supports integrating PrismJS syntax highlighting. Here’s how I pulled it off!
The function below does this whole shebang in several steps:
- Parse our raw markdown content into an AST (Abstract Syntax Tree).
- Apply a plugin we would need to write to detect and convert external links to internal links.
- Convert the markdown AST (used by Remark) into an HTML AST (used by Rehype).
- Apply PrismJS syntax highlighting using a Rehype plugin.
- Render the Rehype AST as React, converting any
<a>
elements intoCustomLink
components along the way. (CustomLink
is a wrapper I created around Gatsby’sLink
so that it works for internal or external links.)
// Render markdown to React
// This pipeline processes raw markdown and converts it into React components,
// applying syntax highlighting and internal link optimization.
const renderMarkdownToReact = markdown => {
try {
return unified()
.use(remarkParse) // Parse markdown into an abstract syntax tree
.use(optimizeInternalLinks) // Apply our plugin to detect internal links
.use(remarkRehype) // Convert Markdown AST to Rehype AST
.use(rehypePrism, { showLineNumbers: false }) // Add PrismJS syntax highlighting
.use(rehypeReact, {
jsx,
jsxs,
Fragment,
components: {
a: props => <CustomLink to={props.href} {...props} />,
},
})
.processSync(markdown).result
} catch (error) {
console.error("Failed to render markdown:", error)
return <div>Error rendering markdown</div>
}
}
And our plugin to convert internal links:
const optimizeInternalLinks = () => tree => {
visit(tree, "link", node => {
const { url } = node
if (url.startsWith("/") || url.startsWith(SITE_URL)) {
const internalPath = url.replace(SITE_URL, "")
node.url = internalPath
}
})
}
This function calls visit
from unist-util-visit
to allow us to walk the AST. Its interface matches what is expected by a Remark plugin.
PHEW. That is quite a pipeline. This is the type of problem I probably would have given up on pre-ChatGPT. With a tool that points me to exactly what libraries to use and gets me close on the initial syntax, I was able to take it the rest of the way.
Now you can click around this blog and, when I reference a past post, it should load nearly instantaneously and preserve your dark mode settings. We did it!
What’s next?
I’m pleased with how the blog has turned out! I’d like to eventually add support for tags, table of contents for each post, and perhaps a series so to link to all the Memphis posts. I could add comments using Disqus but, then again, would you invite a troll into your living room?
I’d love to hear from you! Please reach out if you find any bugs. I’m also curious what static site blog features have wow-ed you in the past, now that I am the proud owner of my own.
I’m gonna go write some Rust now.
Subscribe & Save [on nothing]
If you’d like to get more posts like this directly to your inbox, you can subscribe here!
Work With Me
I mentor software engineers to navigate technical challenges and career growth in a supportive sometimes silly environment. If you’re interested, you can book a session.
Elsewhere
In addition to mentoring, I also write about my experience navigating self-employment and late-diagnosed autism. Less code and the same number of jokes.
- What makes a place feel like home? - From Scratch dot org
Top comments (0)