Next.js with web components and a 0kb bundle

Often, by choosing React you're burdening users with a lot of JavaScript they don't need. Can we get all the benefits of good developer experience without impacting users as a result?

Over the years this website has used four different static site generators. Three of them required just too much compromise for either myself as developer, or for end users:

Jekyll and Eleventy are great in theory. Extremely fast builds, very little opinion forced on you, and rapid speed of development. But in practice, they don't scale to support my future plans and templating languages aren't nice to work with.

Gatsby is probably the king of this sector right now. I like using React, but I don't like the bundle, the plugin ecosystem, and the horrible build speed. While there is a plugin to eliminate client JavaScript, it also kills styled components which apparently don't get server rendered properly.

I have finally settled on Next.js.


I was a huge skeptic of JSX and CSS-in-JS when I started to use React but I have since grown to love how I can think and work. With the additional features in the box with Next, and styled components for nicely encapsulated styles, I don't really want to use anything else. For now.

I use Next in my day job on a very large and complex financial platform. This is not that, so it begs the question: 'Isn't this a bit much for a blog?!'.

Nice DX, nice UX

Next offers a great developer experience. You get:

  • Hot reloading.
  • Option to step through Node processes in an IDE.
  • React dev tools to inspect props and the source in the browser.
  • Other features that come packaged with Next, like data fetching and static props.

Next is a library that uses React, and as such includes a JavaScript bundle. With a little configuration we can lose the bundle, instantly saving kilobytes and shifting the focus to performance. All while keeping the development benefits. Let's get into how this works.


Suppress the bundle

To keep the bundle for the developer but not for the user, you can export a config object in each page that doesn't need React. That is, you're just rendering a page and not using anything like state, context, effects, etc:

export const config = {
  unstable_runtimeJS: false,

This won't have any affect when developing, but will reduce the JavaScript bundle to nothing. Zero. Nil.

Configure Styled Components

This step isn't specific to this approach, but when using Styled Components in Next you need a little extra configuration to render the styles on the server and not just the client. In a regular Next app this prevents FOUC, but in this case it's essential to have any CSS rendered at all.

The extra config required in a custom _document.js can be found in the Next.js repo.

Using web components

I want to JavaScript to handle dark mode, but I also want to ensure that it's as small as possible and that if anyone visits with JavaScript disabled the experience won't be diminished. When using Eleventy I wrote a web component to handle every aspect of a dark mode toggle, from the UI to the detecting OS preference, to local storage.

Like fonts and images, the web component needs to be treated as static content because we don't want Next/Webpack to be aware of or process the JavaScript in any way.

My web component needs to be in the head, so I add the script tag via next/head. It's much more common for JavaScript to be placed just before the closing body tag, and this can be done in the custom _document.js by adding a render method to the class:

render() {
  return (
    <Html lang="en-GB">
      <Head />
        <Main />
        <script src="/scheme-toggle.js"></script>
        <NextScript />

To add the web component element (<scheme-toggle>) to the page, we have to wrap it in a React component. Web components cannot be parsed during server rendering as the DOM API isn't available. React gets upset that the server and client do not match and throws an error. It doesn't prevent a working production build but it does prevent the web component from rendering at all in development mode.

Create a React component that just returns the web component element:

const SchemeToggle = () => <scheme-toggle></scheme-toggle>;

export default SchemeToggle;

Then, import that component with dynamic import and ask it to only render on the server when in production. In development mode it'll only be rendered in the client, avoiding the mismatch and preventing any issues:

const SchemeToggle = dynamic(() => import('../components/SchemeToggle'), {
  ssr: process.env.NODE_ENV === 'production',


When using Next.js with TypeScript there is one more small step. If you start rendering custom elements in JSX TypeScript will (rightly) complain that you're attempting to use an HTML element that doesn't exist. While this is only a type error and the app will still run locally, a producting build of Next in TypeScript will fail if there are type errors.

At the root of the project, create a new declaration file web-components.d.ts. In this file we will extend the JSX namespace and add the web component tag to the elements it's aware of:

declare namespace JSX {
  interface IntrinsicElements {
    'scheme-toggle': any;


We can have all the benefits of the JAM stack when developing without transferring non-essential JavaScript to the user. The production build delivers static HTML and CSS with just 1.35kb on non-essential JavaScript (which could be marginally less if I added a step to minify it). This, combined with a little extra optimisation (subsetting and preloading font files, using webp images) leads to a fast and accessible site with a perfect lighthouse score.

More posts


CSS environment variables


Setting up a new Mac