You don’t need a static site generator

Niklas Klein
4 min readFeb 17, 2024

--

Photograph of many small gears in a pile
Photo by Jonathan Borba

There are tons of static site generators out there, usually used for making blogs, sometimes even entire websites or knowledge bases. But they all have one thing in common: they suck. First of all, it’s hard to choose one because there are so many, and it’s almost impossible to figure out the pros and cons to compare them without actually using them. Once you’ve made up your mind and picked one, chances are you will run into walls pretty quickly. Static website generators tend to be quite opinionated and rigid, so you’ll end up spending your time forcing the tool to do something it wasn’t designed to do.

If you can relate to this experience, I’d like to show you a different approach: using Next.js (app directory) as our static site generator. It’s not an off-the-shelf solution, but it already does most of the heavy lifting (and a lot more) for us. All you need to bring is a bit of glue code and your opinions on how you want your own static site generator to work.

In this article we will create a simple blog generation engine that works like this:

  • Articles are generated from *.yml files in the ./content/ directory
  • An article definition is a YAML object with three keys:
    slug: a unique identifier used for the URL path
    title: used as a <h1> on the article page as well as for the <title> tag
    body: markdown formatted text used for the content of the article
  • For each article a separate page is generated
  • An index page is generated that links to every article

Start by scaffolding a plain Next.js project and initializing a ./content/ directory where we drop our blog articles, e.g. 001-my-first-article.yml:

title: My first article
slug: first-article
body: |-
Lorem ipsum dolor sit amet, ...

Now create ./app/engine.ts where we read and parse the content files. We rely on zod and yaml as external dependecies.

import { readFile, readdir } from "fs/promises"
import { parse } from "yaml"
import { z } from "zod"

const Article = z.object({
title: z.string(),
slug: z.string(),
body: z.string()
})

export type Article = z.infer<typeof Article>

export const loadArticles = async (): Promise<Article[]> => {
const entries = await readdir("./content", { withFileTypes: true })
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".yml"))
const sources = await Promise.all(files.map((file) => readFile(`${file.path}/${file.name}`, "utf-8")))
return sources.map(parseArticle)
}

const parseArticle = (input: string): Article => {
const yaml = parse(input)
return Article.parse(yaml)
}

Next, we define our single article page in ./app/[slug]/page.tsx. We use react-markdown to render the article body.

import { loadArticles } from "@/app/engine"
import { notFound } from "next/navigation"
import Markdown from "react-markdown"

export default async ({ params }: { params: { slug: string } }) => {
const articles = await loadArticles()

const article = articles.find((article) => article.slug === params.slug)

if (article === undefined) notFound()

return (
<>
<h1>{article.title}</h1>
<Markdown>{article.body}</Markdown>
<hr />
<a href="/">Back to overview</a>
</>
)
}

In order for static generation to work, we have to supply the generateStaticParams function that provides a list of all existing article slugs to Next.js:

export async function generateStaticParams(): Promise<{ slug: string }[]> {
const articles = await loadArticles()
return articles.map((article) => ({ slug: article.slug }))
}

And finally, we add the generateMetadata function in order to populate the <title> tag with with title of the article:

import { Metadata } from "next"

export async function ({ params }: { params: { slug: string } }): Promise<Metadata> {
const articles = await loadArticles()

const article = articles.find((article) => article.slug === params.slug)

if (article === undefined) notFound()

return {
title: article.title,
}
}

We now already got a minimal blog generator going. Let’s move on to the index page which we define in ./app/page.tsx:

import { loadArticles } from "./engine"

export default async () => {
const articles = await loadArticles()

return (
<>
<h1>Welcome to my blog</h1>
<ol>
{articles.map((article) => (
<li key={article.slug}>
<a href={`/${article.slug}`}>{article.title}</a>
</li>
))}
</ol>
</>
)
}

That’s it, really. It’s not much, but it is an incredibly solid foundation. You can edit your next.config file now to force a static output when executing next build:

const nextConfig = {
output: "export"
}

Where to take it from here?

It’s up to you. The basic building blocks are there, it’s your turn now to evolve it into your static site generator. Some things that come to my mind:

  • Pull in your favorite UI toolkit to make it look nice
  • Show creation and updated timestamps of articles and sort them accordingly
  • Add support for authors, tags and search
  • Add teaser and preview images to articles
  • Support media and syntax highlighting blocks
  • Enhance the generated metadata with OpenGraph attributes
  • Add i18n support to publish your content in multiple languages
  • Replace my Medium blog with my own static blog generator (-:

Conclusion

In this tutorial we ditched the traditional static site generators to roll our own solution on top of Next.js instead. It certainly takes us longer to get started this way, but I’m confident we’ll benefit in the long run as we spend our time adding features the way we want them, rather than fighting existing static site generators to do something they weren’t designed to do.

Advantages

  • Next.js gives us a rock solid foundation for static site generation
  • We are not limited by the opinions enforced by existing static site generators
  • Can be directly integrated into existing Next.js websites
  • Can easily be adjusted to generate documentation or knowledge base pages

Disadvantages

  • The static output of Next.js cannot be published to GitHub pages because we need URL rewrites, which are not supported by GitHub pages
  • Not an off-the-shelf solution that gets us started immediately

You can find the full source code for this tutorial on GitHub.

--

--