Overengineering a Next.js developer blog in 2021
Andrew Hurle@adhurle
Apr 19, 202111 min read
ShowHide Table of Contents
Table of Contents
I've always wanted a website. They're fun! I've made lots of them, but never one just for me and my long-winded opinions. After a couple of months of on-and-off work, it's live and open source! Here's how I built it.
Requirements
At first, I entertained the idea of sticking HTML and CSS files onto my Raspberry Pi, running a web server, and calling it a day. Immediately, there were too many cons with this method: I don't want my website to go down if someone trips over a wire in my apartment, and I would miss the CSS-in-JS niceties I've grown accustomed to in React. Who wants to worry about className typos in this day and age?
Next I considered pre-built platforms like Medium, Substack, or Ghost. These would get me up and running with zero effort, but I have some time on my hands. I'd rather have total control over what my website looks like, and anyway, building something on my own as a learning exercise would be fun.
I did a little research and decided that Devii would be a good starting point. Devii is a developer blog starter kit built on a stack that I'm comfortable with: Next.js, React, and TypeScript. Colin McDonnell, the creator of Devii, wrote about his design rationale in this essay, and I highly recommend reading it as context for the rest of my post. In addition to his requirements, I wanted my site to be:
Functional with JavaScript disabled. In the end I'm just trying to show you some text and images, so you should be able to read and navigate the site without JS.
Fast and light. Not only is page speed important for SEO, it is morally wrong to make someone wait around for 2MB of JS to execute in order to show them some text. (Proverbs 3:27-28)
Themeable. Dark mode is cool, and I have some fun novelty theme ideas 💅
Testable. Detecting problems like SSR mismatches or uncaught exceptions ought to be trivial and automated.
Trimming the Fat
After a quick fork of Devii and some dependency upgrades, the first thing I looked into was page speed; If Devii needed some kind of massive dependency in order to function, then I could bail out early and find some other codebase to fork.
The initial results were not terrible, but not great either.
That's a whopping 357KB of gzipped JS (1 meg uncompressed) just to render a blog post. The PageSpeed score was 69 on mobile and low 70s on desktop, so it was slower than necessary even when the CPU wasn't artificially throttled. I should do something about how slow this is.
I installed
@next/bundle-analyzer
and looked around for easy wins. Luckily, there were a few!
First of all, I found out that Devii's <Code>
component imports react-syntax-highlighter
in a wasteful manner:
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
Importing it like this will include syntaxes for all 237 supported languages in the client bundle (~500KB uncompressed), including unquestioned industry powerhouses like SQF and MEL. I'm not planning on making an Arma 3 mod anytime soon, so these can be safely removed from my bundle.
The solution was to import the PrismAsyncLight
component instead of Prism
, since it only loads the languages you use on the page - nice! Unfortunately, switching over to it makes our SSR output lack syntax highlighting, which goes against the "no JS necessary" requirement. I was able to get around that problem with this little trick:
import { PrismLight, PrismAsyncLight } from "react-syntax-highlighter"
const SyntaxHighlighter =
typeof window === "undefined" // window is undefined during server render
? PrismLight
: PrismAsyncLight
This will make the server output highlighted code sections, without sacrificing the client bundle size wins from PrismAsyncLight
. As a bonus, I ended up discovering a couple of SSR mismatch issues in react-syntax-highlighter
and have a couple PRs up to fix them:
[1]
[2]
(patched into my repo here).
Another silly problem was this import statement:
import { darcula } from "react-syntax-highlighter/dist/cjs/styles/prism"
This causes the bundling of all 37 available prism styles (~100KB uncompressed). We only need the one, so we can fix it like so:
import darcula from "react-syntax-highlighter/dist/cjs/styles/prism/darcula"
After this first cut of optimizations, the final JS weight came out to 178KB gzipped and 519KB uncompressed, or about a 50% reduction. I could stomach those numbers. And they get even better later on!
Note: After writing this article, I upstreamed these optimizations into Devii itself
Testing
The next thing I looked into was how I could write automated tests for my pages. And I really mean entire pages - In Rails I'm used to calling get "/some/page"
and then inspecting the HTML or HTTP status code.
I was really bummed out to see the Next.js documentation has
no advice for testing your application.
You can use
react-testing-library
to call something like render(<SomePage someProp={1}>)
, but that does nothing to assert that routes map to the right React components, or that your
getStaticProps
function is correctly wired up.
Luckily for me,
Andrea Carraro had the same concerns
and addressed them with a library called
next-page-tester
.
You can give it a path to "visit", and it will behave just like Next.js behaves. It will render the page on the "server", hydrate that page on the "client", and then you make assertions against either the server or client representations of the DOM.
You can find an example of next-page-tester usage in my codebase,
where I make sure the server-rendered HTML includes an analytics pixel <noscript>
fallback. Combined with
jest-fail-on-console
I can easily catch SSR mismatches, since they result in console.error
calls.
Getting next-page-tester
to work with CSS-in-JS libraries was a little tricky. I was getting weird exceptions in the test environment, and then I spent quite a while stepping through the debugger trying to figure out why.
I provided a minimal reproduction of the problem and a janky patch to the team,
which quickly led to a proper fix in v0.21.0.
So if you end up using next-page-tester
in such a way, you're welcome 😘 Hats off to the maintainers for fixing problems so quickly!
Switching to MDX
Devii has an awesome feature where you can author blog posts in markdown - this a major reason why I chose it. However, there were a couple of awkward things about how it all worked:
Devii sends your raw markdown content to the browser, alongside a full-blown markdown parser. Since Devii's flavor of markdown allows HTML, it also includes an HTML parser. This adds up to a hefty ~40KB gzipped.
react-syntax-highlighter
has to be included in the client bundle, as you saw above. I cut it down in size quite a bit, but it still took up ~20KB gzipped on my example blog post.When React hydrates the DOM, it has to first parse the markdown into JSX, and then parse out the code blocks for syntax highlighting. This can take hundreds (or thousands!) of milliseconds of CPU time on mobile.
Markdown is rendered with a component like so:
<Markdown source="[a link](google.com)..." />
. If you want to interleave arbitrary React components inside that markdown, you'd need to break out of Devii's blog post abstraction or use a remark plugin.Metadata for a blog post (title, publish date, etc.) comes in the form of front-matter, which I don't really like since it doesn't have the full expressive power of writing JS.
There's a good solution for all of these problems called MDX. Rather than shipping all the parsers we need to the browser, we can do all the parsing at build time and ship pure JSX to the browser. This lets you go crazy with parser plugins without worrying about bundle size.
Another benefit is that you can basically write JS wherever you want. You can export variables instead of using front-matter. You can import arbitrary React components and render them inline with your markdown, or just write JSX in place of HTML. That's very powerful! For example, here's a component that increments a number when you click on it:
I was a little scared of using MDX since it has neither TypeScript nor Intellisense support. However, it does have an eslint plugin and a VSCode extension which can catch a lot of problems:
After the switch to MDX, here's what the PageSpeed score looked like on my example blog post:
Not bad! Now we're down to 116KB of JS gzipped, 326KB uncompressed - nearly a 70% reduction from where we started.
Styling
Devii uses a plain old CSS file out of the box, but my styling was going to be a bit more complex, especially considering my desire for multiple themes. Also, traditional CSS carries the risk of className typos.
My first thought was to use
styled-components
-
it's super popular and I've used it before. But then I realized that implementing
the perfect dark mode
is a bit complicated. Also,
the CSS-in-JS market is much more crowded now than it was 2-3 years ago
when I started using styled-components.
After lots of research, I ended up choosing
theme-ui
,
which uses
emotion
.
The big reason I chose it was because it has
first-class support for "color modes"
via CSS custom properties, including respect for the system default color scheme and avoiding light mode flashes. The less code I have to write, the better!
Built-in MDX support
and the
terse styling syntax
looked good, too.
Unfortunately, I ran into a few problems with theme-ui
once I was already committed:
When JS is disabled,
theme-ui
does not respect the default system color mode, even though it's possible with CSS alone for basic themesTypeScript can't protect you against certain typos, but this problem is actively being worked on
I'm pretty sure that
theme-ui
regressed my PageSpeed scoreI ran into a bug in
emotion
that sometimes breaks fast refresh
I'm in too deep now, so I hacked around some of these problems. If I could go back in time, however, I'd look into using styled-components
instead and find something else to handle color modes. That's what I get for using 0.x
versions of libraries!
Anyway, as a
back-of-the-front-end
guy, I'm rather proud that I got everything styled nicely, even with JS disabled. For example, the mobile header opens and closes because it's just a
details
element,
so I can use the details[open]
CSS selector to style it differently when expanded. Similarly, the "Show More" button on the homepage is actually
a hidden checkbox.
Sara Soueidan has an excellent article about this technique.
Other Neat Features
- Free analytics from GoatCounter
- Free hosting from Vercel so I can see each commit in a staging environment
- ESLint, Prettier, Jest, and a CI config for Github Actions - Free!
- Newsletter signup with TinyLetter
- Non-blog-post pages can be written in MDX and get automatically tested by
next-page-tester
- MDX image size data is automatically provided to
next/image
for lazy loading - Excerpt, Table of Contents, and Reading Time get extracted from each blog post
- MDX "front-matter" is just a JS object export, validated by zod
- I took the time to use the site via keyboard/VoiceOver, so hopefully I did a good job with accessibility
- Handcrafted, artisanal SVGs
Even though it's not perfect and it took a while, I'm really happy with how my site has turned out and I learned a lot along the way. If you'd also like a fun website, fork my repo on github! All I ask is that you style it a little differently - make it "yours". It would also be nice if you linked back to me, but it's all MIT licensed so... 🤷♂️
Want to hear about how to integrate GoatCounter with Next.js, the details of my MDX setup, or how my little theme switcher works? Subscribe to my newsletter below!
Edited by Danielle Nguyễn