Improving bundle size of Next.js app

Improving bundle size of Next.js app

Shipping only modern JavaScript

Next.js currently targets ES5 for JS output and autoprefixes CSS in order to support legacy browsers. This results in much larger JS and CSS output because newer features need to be down-leveled. You can opt-out of this behavior with experimental flags below:

next.config.js
const nextConfig = {
  experimental: {
    legacyBrowsers:  false,
    browsersListForSwc:  true
  }
}

module.exports = nextConfig

Use next/future/image

The next/future/image component improves both the performance and developer experience of next/image by using the native <img> element with better default behavior. This component uses browser native lazy loading.

component.jsx
// Before
import Image from 'next/image'

// After
import Image from 'next/future/image'

You might need to tweak some properties, because layout is not available.

Use next/dynamic imports

Next.js supports lazy loading external libraries with import() and React components with next/dynamic. Deferred loading helps improve the initial loading performance by decreasing the amount of JavaScript necessary to render the page. Components or libraries are only imported and included in the JavaScript bundle when they're used.

next/dynamic is an extension of React.lazy. When used in combination with Suspense, components can delay hydration until the Suspense boundary is resolved.

By using next/dynamic, the header component will not be included in the page's initial JavaScript bundle. The page will render the Suspense fallback first, followed by the Header component when the Suspense boundary is resolved.

import dynamic from 'next/dynamic'
import { Suspense } from 'react'

const DynamicHeader = dynamic(() => import('../components/header'), {
  suspense: true,
})

export default function Home() {
  return (
    <Suspense fallback={`Loading...`}>
      <DynamicHeader />
    </Suspense>
  )
}

We can also defer loading external libraries until module is needed, a.k.a user types in the search input.
This example uses fuse.js.

import { useState } from 'react'

const names = ['Tim', 'Joe', 'Bel', 'Lee']

export default function Page() {
  const [results, setResults] = useState()

  return (
    <div>
      <input
        type="text"
        placeholder="Search"
        onChange={async (e) => {
          const { value } = e.currentTarget
          // Dynamically load fuse.js
          const Fuse = (await import('fuse.js')).default
          const fuse = new Fuse(names)

          setResults(fuse.search(value))
        }}
      />
      <pre>Results: {JSON.stringify(results, null, 2)}</pre>
    </div>
  )
}

Replace react with preact

Preact is a fast 3kB alternative to React with the same modern API. It doesn't ship Virtual DOM and uses native browser APIs. You can give it a shot, but some packages might not work and you might run into some issues. Make sure to test changes before deploying to production!

Here is basic package.json needed for replacing React with Preact

package.json
{
  "scripts": {
    ...
  },
  "dependencies": {
    "next": "latest",
	"next-plugin-preact": "latest",
    "preact": "latest",
    "preact-render-to-string": "latest",
    "react": "npm:@preact/compat",
    "react-dom": "npm:@preact/compat",
    "react-ssr-prepass": "npm:preact-ssr-prepass"
  },
  "devDependencies": {
    "@types/node": "latest",
    "@types/react": "latest",
    "typescript": "latest"
  }
}

Then you need to tweak your next.config.js to use next-plugin-preact:

next.config.js
const withPreact = require('next-plugin-preact')

/** @type {import('next').NextConfig} */
const nextConfig = {
/* regular next.js config options here */
}

module.exports = withPreact(nextConfig)

And that's it. There is no need to replace your imports, because preact has a combability layer with react. Here is a bundle size comparison:

Route (pages)                              Size     First Load JS
┌ ○ /                                      2.39 kB        79.6 kB
├ ○ /404                                   194 B          77.4 kB
├ ○ /about                                 274 B          77.5 kB
├ ● /ssg                                   305 B          77.5 kB
└ λ /ssr                                   305 B          77.5 kB
+ First Load JS shared by all              77.2 kB
  ├ chunks/framework-7dc8a65f4a0cda33.js   45.2 kB
  ├ chunks/main-18053c3f67c4d467.js        31 kB
  ├ chunks/pages/_app-dc14f8483464b560.js  201 B
  └ chunks/webpack-69bfa6990bb9e155.js     769 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
Route (pages)                              Size     First Load JS
┌ ○ /                                      2.4 kB           43 kB
├ ○ /404                                   194 B          40.8 kB
├ ○ /about                                 271 B          40.9 kB
├ ● /ssg                                   305 B          40.9 kB
└ λ /ssr                                   303 B          40.9 kB
+ First Load JS shared by all              40.6 kB
  ├ chunks/framework-f2746757f5385dad.js   8.73 kB
  ├ chunks/main-632edec26452657f.js        30.9 kB
  ├ chunks/pages/_app-dc14f8483464b560.js  201 B
  └ chunks/webpack-69bfa6990bb9e155.js     769 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

As you can see, we stripped out about 37kB of JS.
Because of that, browser doesn't have to interpret a lot of JS (37kB is getting unzipped) which will lead to performance gains.


See all posts