How to Make a Static Website with Next.js
đ Hey, so I recently figured out how to upgrade this very website to use Next.js 3 as a static blog engine. No longer is my corner of the internet a cobbled-together mess of node and of shell scripts. No, now itâs a fully-fledged modern JavaScript app with Next.js 3!
Now I can write and edit posts in Markdown and can even drop in custom HTML if I need to (I mean how else am I gonna incorporate <marquee>
tags into my writing?). With Next.js, I get all sorts of fancy features like service worker prefetch, code splitting, and SPA style route changesâall for free. Hereâs how I did it.
As soon as the Zeit team announced plans to support serverless static exports, I was ready to go: I had already tried out Next.js for a few side projects, but didnât want to worry about running a server (even if itâs free and painless) to keep my website up. I even tried scraping a compiled Next.js 2 app with wget
to make a static site, so Nextâs official support for static sites had me running out of excuses.
Here were my requirements for my humble website:
- Fully static and deployable on Github pages.
- Author posts in Markdown, with support for HTML things like
<details>
tags. - Fast. Nobodyâs got time for slow websites.
- Support my CSS preferences: Tachyons and some custom CSS compiled with postcss
Next.js seems to have checked off all my boxes, so I dug in and started prototyping.
â The Mythical Man-Month, Fred Brooks (as popularized in The Cathedral and the Bazaar by Eric Raymond)âPlan to throw one away; you will, anyhowâ.
I built a prototype on the Next.js 3 beta to kick the tires. I learned a few things along the way but didnât end up with a website that was ready to deploy.
Too bad I wasnât very happy with my first pass. I added a necessary-but-clunky build step to convert my old posts to Nextâs routing model. The plan was to compile my markdown posts and write out files to the pages/
directory. Next.js would pick them up whenever they changed, but I didnât like having to run two build scripts.
Nextâs biggest selling point is having live reloading figured out already! Having to run more than one script felt wrong, so I abandoned the prototype.
A brief aside re: a nifty markdown rendering pipeline with Unified and Remark that should probably be in it's own post, but laziness.
â The Cathedral and the Bazaar, Eric RaymondEvery good work of software starts by scratching a developerâs personal itch.
đ° Rabbit Hole: React from HTML Markdown
One of the itches I really wanted to scratch was the minor annoyance of having to use __dangerouslySetInnerHtml
to use most off-the-shelf markdown libraries with React. I even made it a bit harder on myself by lazily abusing markdown and sprinkling bits of markup in many of my posts, since most React components that render markdown tend to fall back to dangerouslySet instead of parsing the markdown to generate a valid React component for the entire markdown document. This isnât a new or unsolved problem, so I did some research and ended up geeking out on text processing and abstract syntax trees. Turns out that there are already a bunch of well documented AST parser/compilers that support markdown on npm!
I really didnât want to make clients do any of the parsing work. Even though parsing markdown can be optimized to be fast in modern browsers, making users download additional JavaScript and spend CPU time to convert posts clientside just didnât sit well with me.
I decided that in order to handle all of my posts with their mix of markdown and html, I would use Unified to make a rendering pipeline to go from markdown to HTML to a set of React components. There were already unified plugins for everything I wanted to do!
There was even a ready-made solution for my exact gripe about __dangerouslySetInnerHtml
! Unfortunately, remark-react handles most cases but didnât want to parse the raw HTMl generously sprinkled throughout my posts. Either way, I had found a small ecosystem of node modules that would make short work of lots of text processing problems. Neat!
Hereâs what the code ended up looking like:
const unified = require('unified')
unified()
.use(require('remark-parse'), {
gfm: true,
footnotes: true
})
.use(require('remark-rehype'), {
allowDangerousHTML: true
})
.use(require('rehype-raw'))
.use(require('rehype-react'))
To my surprise and delight, that process pipeline resulted in a totally usable React component! But it still would require some redundant processing on the client since the React component was being generated dynamically from a string of markdown.
So how do you cache a React component? Like, a whole component, not just the serialization of itâs virtual dom. React provides tools to server render components in multiple ways, but you canât easily generate jsx from a dynamically generated components. But there is a technique for dealing with a React as a compile output of an AST, evidenced by react-rehype at the end of that Unified markdown pipeline.
React has a dead simple API for creating components without JSX in React.createElement()
. Since itâs just plain JavaScript and doesnât require any functions or non-json data structures, it turns out that you can make a JSON structure that represents a set of React components pretty easily. I had run into a use case for this same trick at work, so I put it to use again here: I modified the last step of my Unified pipeline to return JSON instead of a React component. rehype-react
made this a cinch, since they allow you to pass a custom method for createElement
.
remarkPipeline().use(rehypeReact, {
createElement: (type, props, children) => ({ type, props, children })
})
From there, I made a simple component to transform the result from rehype-react
back into a React component:
<ComponentTree components={components} />
Now I have an pipeline where you can put markdown with crazy embedded HTML in one end, and well-formed serializable React components come out of the other end. With that, I can write out JavaScript files containing valid React components without having to reconstruct any JSX literals from the rehype AST. Either way thatâs a step that I wanted to be transparent when I was writing posts. Mission accomplished đ
There are a couple of benefits from going through all that trouble:
- remark plugins can do just about anything. Seriously. I was able to add code highlighting while I was writing this post with 1 npm install, 1 line of JavaScript, and 1 line of CSS!
- Unifiedâs vfile format makes adding post metadata easy.
- No format lock in. When the wind blows a differnt direction and React falls out of favor, outputting to a different format will be easy.
Markdown âĄď¸ Webpack âĄď¸ Next.js
I wanted to write in Markdown and have Next.js pick up the changes automatically.
By default, next will use any JavaScript modules that export a React component in your pages/
directory. While writing markdown next to code is possible, itâs gross. Same goes for duplicating the same boilerplate file for each post and importing the markdown source from some other directory. I wanted to skip all that an go straight from Markdown into Nextâs build and compile pipeline.
đĄ The light bulb moment came when I realized the power of Nextâs support for custom Webpack configuration.
A webpack loader can transform markdown source files into modules on the spot! And better yet, I already had a build script from my prototype that was doing most of what I needed to do in the loader. I needed to change was how my build script found out about files (reading them from disk vs. passed in by webpack) and how it output the results (again, writing to disk vs. passing the result back to webpack). The loader plugin interface was dead simple:
module.exports = function(source) {
const done = this.async()
renderPost(source, this.resourcePath)
.catch(done).then(post => done(null, post))
}
Where renderPost(source, resourcePath)
was the middle bit of my prototyped static rendering pipeline, refactored to only need a string of the file content and the path of the file being rendered. This is one of the easiest changes to make, since it took a method formerly reliant on side effects, namely reading and writing to disk, and made it a pure function. Any time you can make a method thatâs passed an input and return a result, you should. Decomposing your assumptions about side effects will almost always save time. I learned this from Gary Bernhardtâs talk Boundaries, and I remember it every time I see it.
The last step was to add it to the webpack extension point in next.config.js
:
module.exports = {
webpack(config) {
config.module.rules.push({
test: /\.html\.js$/,
include: './pages/writing',
loader: './src/post-loader'
})
return config
}
}
Building and Deploying
Unlike in my earlier versions, Next.js 3 supports creating a complete static site from any next app with next export
. To tell it what routes and pages to export, you need to add some configuration to next.config.js
. Hereâs what mine looks like:
module.exports = {
exportPathMap() {
return {
"/": { page: "/" },
"/cv.html": { page: "/cv.html" },
"/writing/2016-reading-list.html": { page: "/writing/2016-reading-list.html" },
"/writing/2017-reading-list.html": { page: "/writing/2017-reading-list.html" },
// ...
}
}
}
Then I changed my build step to run next build && next export --docs
and I with surprisingly little drama was ready to deploy to Github pages!
Normally Iâd be worried that Iâd have missed some minor detail in a major change like swapping out the entire backend of a website, but in essence what I was doing here wasnât all that big of a change: my static HTML, JavaScript, and CSS in the docs/
directory was still there, but was being built by a different tool chain. I pushed the first commit with the switch to Next.js and waited patiently while the build ran on Travis CI.
âŚAnd that was it. I had more or less completely moved my static site into a totally modern React app with Next.js. The whole thing is open source, so feel free to kick the tires and ask questions if you have any đ
Here are the relevant parts of the app:
- post-loader.js â webpack loader
- static-sites-with-next-js.html.js â a markdown post with embedded HTML
- react-to-hast.js â markdown compiler with Unified.js
- component-tree.js â React component for rendering JSON AST
Gotchaâs! đ
A few snags I ran into:
- Github pages still perversely retains some of itâs Jekyll roots, and ignores file and directory names that start with an underscore đ
- Fix: add
.nojekyll
to yourdocs/
directory (or whatever is configured in the âPagesâ portion of your repo config in Github)
- Fix: add
- The
.html.js
file extension on the posts was because I wanted backwards compatibilty with my static html version, which used plain old html files- Minor annoyance:
next export
adds directories for every static file to avoid the .html extension showing up in the path, but now I have urls with trailing slashes. - If I ever want to change a URL (like to drop the
.html
extension because itâs not 1998), Iâll need to figure out how to manage the redirects.
- Minor annoyance:
- I need to remember to add new posts to
next.config.js
, which I seem pathologically incapable of. Iâll probably make the webpack plugin emit a JSON file with all the post metadata, but I havenât done that yet.