Overengineering a Next.js developer blog in 2021

Closeup of Andrew Hurle, smiling

Andrew Hurle@adhurle

Apr 19, 202111 min read

Andrew lying on a couch wearing sunglasses, somehow typing on two laptops simultaneously, smiling at the camera
Protip: Always use two laptops to maximize typing speed
ShowHide Table of Contents

Table of Contents

  1. Requirements
    1. Trimming the Fat
      1. Testing
        1. Switching to MDX
          1. Styling
            1. Other Neat Features

              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.

              Build output from Next.js indicating a large bundle size
              PageSpeed Insights showing a mobile score of 69. Nice.

              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:

              Screenshot of VSCode that shows eslint highlighting an import error, a stylistic problem, and an image accessibility issue

              I updated the documentation for the VSCode extension to help you see red squigglies too!

              After the switch to MDX, here's what the PageSpeed score looked like on my example blog post:

              PageSpeed Insights showing a mobile score of 98

              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:

              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!

              🙏 Thank You For Reading My Blog 👋

              Enjoy the article? Want to hear more?

              Subscribe to my newsletter!

              Don't like emails? Try the RSS Feed

              ← Read a different article