Portrait of Stuart Thomson

Stuart Thomson

Software Developer | Human Being

Removing Javascript from a Next.js site... and then adding it back in
Removing Javascript from a Next.js site... and then adding it back in

Removing Javascript from a Next.js site... and then adding it back in

Published
Published March 8, 2022
Edited
Tags
#next.js
#web-components
#react
#typescript
Next has an option to remove all of its Javascript from the HTML that gets generated in the build. The reasons for doing this may vary, from reducing bandwidth usage to finding a reason to write a new blog post. The reason I started looking into it is that I knew my website didn’t really need React running in the browser, even though I wanted to write the code for it in React. I thought this would be an interesting rabbit hole to follow, and it sure was. Here, I’ll try to summarise all of the findings I had, and illustrating some of the pitfalls I had along the way.

Disabling the Next.js runtime

Disabling the runtime Javascript is done on a per-page basis, meaning you need to include the following snippet on all pages you wish to affect. The configuration is not in your next.config.js, which took me far too long to figure out. This decision to make it opt-in per page was made by the Next team so that other pages, which may expect the runtime to be present, were not affected.
tsx
// pages/index.ts, or whatever page(s) you wish
export const config = {
unstable_runtimeJS: false,
};
This will play nicely with Next’s incremental static generation, as long as you set fallback: 'blocking' when returning from getStaticPaths. With this, Next will continue to run React on the server side to generate fresh pages without needing a full site rebuild. I’m not sure I would have continued with this project if it didn’t.

The effects of removing the runtime

So what happens when the runtime is gone? To test this I took a blog post from my website, ran next build with the unstable_runtimeJS option set to both true and false, and then formatted the output so that the diffs would be clearer.
diff
<noscript data-n-css=""></noscript>
- <script defer="" nomodule="" src="/_next/static/chunks/polyfills-5cd94c89d3acac5f.js"></script>
- <script defer="" src="/_next/static/chunks/175675d1.e67822bbbe79cc09.js"></script>
- <script defer="" src="/_next/static/chunks/795.e7a033cda3425447.js"></script>
- <script src="/_next/static/chunks/webpack-a410870cc668a121.js" defer=""></script>
- <script src="/_next/static/chunks/framework-8957c350a55da097.js" defer=""></script>
- <script src="/_next/static/chunks/main-812e7e0b8fe6ca6c.js" defer=""></script>
- <script src="/_next/static/chunks/pages/_app-10e968bc3330e74f.js" defer=""></script>
- <script src="/_next/static/chunks/61-ecec630c348f2367.js" defer=""></script>
- <script src="/_next/static/chunks/505-dc2a4167b520aa71.js" defer=""></script>
- <script src="/_next/static/chunks/pages/blog/posts/%5Bslug%5D-8fe43dfbc7a1d7d0.js" defer=""></script>
- <script src="/_next/static/L4ktLEoM0MrXp5TNPMVYf/_buildManifest.js" defer=""></script>
- <script src="/_next/static/L4ktLEoM0MrXp5TNPMVYf/_ssgManifest.js" defer=""></script>
- <script src="/_next/static/L4ktLEoM0MrXp5TNPMVYf/_middlewareManifest.js" defer=""></script>
<style data-styled="" data-styled-version="5.3.3">
...
</div>
- <script id="__NEXT_DATA__" type="application/json">
- {
-
- // Props for the page here
-
- }
- </script>
</body>
Obviously, removing the scripts, uh, removes the scripts. Next does some code splitting to share chunks across pages, so the number of scripts included depends on the exact setup of the website. It’s worth pointing out that these aren’t all massive, either. The manifests at the end are tiny in size, and many chunks will be code for very specific parts of your app.
The only other change in the diff is the removal of the __NEXT_DATA__ JSON. This is used when Next “hydrates” the app when the browser loads the first page. Next passes these props to your components in the browser to jump-start the React app into the same state (hopefully) as what was on the server. Of course, when you’re not using React in the browser then you don’t need this data in the output, so it’s removed from the no JS build.

Adding scripts back in with Web Components

There are many approaches I could have taken to add little bits of functionality back into my site, but I ultimately settled on web components.

Using Web Components in Typescript

Custom components don’t get defined as elements automatically in Typescript, so you do need to create types for them. There is a simple way:
tsx
// A basic .d.ts file
declare namespace JSX {
interface IntrinsicElements {
'my-custom-element': any;
}
}
The above is easy for just getting rid of compiler errors. The unfortunate part is the type being any, taking away any potential benefits of using Typescript.
I would have been lost if it weren’t for another post addressing this exact problem. Below is their solution, with one minor tweak.
tsx
type CustomEvents<K extends string> = { [key in K]: (event: CustomEvent) => void };
type CustomElement<T, K extends string | undefined = undefined> = Partial<
T &
DOMAttributes<T> &
{ children: any } &
(K extends string ? CustomEvents<`on${K}`> : {})
>;
declare global {
namespace JSX {
interface IntrinsicElements {
'my-custom-element': CustomElement<MyCustomElement>;
'element-with-events': CustomElement<CustomElementWithEvents, 'EventOne' | 'EventTwo'>;
}
}
}
The change I made was to make K (the names of any custom events the element might take) optional, and to remove the on[empty string] event that was otherwise defined in the type.

Downsides to this approach

  1. No (automatic) code splitting.
    1. One of the nice features of the modern React ecosystem, including Next.js, is code splitting. Only the Javascript required to render a page is loaded, and when moving to the next page only the new code required is requested. With my particular setup, this isn’t possible. esbuild (which I’ve chosen to use) only supports code splitting for ES Modules, which I didn’t want to use in the browser.
      An alternative is to bundle each component separately, and then include the relevant script tags on the pages that use them. However, this leads to the situation where dependencies aren’t shared and are present in multiple bundles.
  1. It’s extra tooling.
    1. You’re effectively maintaining two applications. One is being used to create the original page, but the other is the code that will run in the browser. It’s two separate codebases with two different build pipelines, and that effort just might not be advantageous.
  1. React server components are coming soon. I feel that React’s server components are going to fill this gap better than any other solution. Once library support hits a decent threshold, only the code that needs to run on the client will be sent, and the future of server-side rendering will change form again.
Comments (GitHub)