Skip to content

Instantly share code, notes, and snippets.

@naoisegolden
Last active September 30, 2023 07:48
Show Gist options
  • Save naoisegolden/17626c669aef6c53a64c642b3640d785 to your computer and use it in GitHub Desktop.
Save naoisegolden/17626c669aef6c53a64c642b3640d785 to your computer and use it in GitHub Desktop.
SSR and Code-splitting in Stuart FE apps

Next.js compared to our and other solutions

Figuring out how Next.js handles code splitting and routing so that we can decide if it fits our needs or if we can take inspiration from it for our own internal solution.

SSR

SSR with Next.js

There is no special documentation about Next.js SSR, since it's supported out of the box and handled transparently.

Next.js allows to customize the webpack config and provides an isServer boolean:

// next.config.js
module.exports = {
  webpack: (config, { buildId, dev, isServer, defaultLoaders }) => {
    // Perform customizations to webpack config
    return config
  },
  webpackDevMiddleware: config => {
    // Perform customizations to webpack dev middleware config
    return config
  }
}

It also allows to separate server-only runtime config:

// next.config.js
module.exports = {
  serverRuntimeConfig: {  
    // Will only be available on the server side
  },
  publicRuntimeConfig: {
    // Will be available on both server and client
  }
}

Some sneak peaks on Next's internal server-side rendering and client-side rendering:

// packages/next-server/server/next-server.js

// Only serverRuntimeConfig needs the default
// publicRuntimeConfig gets it's default in client/index.js
const { serverRuntimeConfig = {}, publicRuntimeConfig, assetPrefix, generateEtags } = this.nextConfig;

// Abstracts route creation in a helper class
const routes = this.generateRoutes()
this.router = new Router(routes)
this.setAssetPrefix(assetPrefix)
// ...
async run (req, res, parsedUrl) {
  try {
    const fn = this.router.match(req, res, parsedUrl)
    if (fn) {
      await fn()
      return
    }
  } catch (err) {
    if (err.code === 'DECODE_FAILED') {
      res.statusCode = 400
      return this.renderError(null, req, res, '/_error', {})
    }
    throw err
  }

  if (req.method === 'GET' || req.method === 'HEAD') {
    await this.render404(req, res, parsedUrl)
  } else {
    res.statusCode = 501
    res.end('Not Implemented')
  }
}

// Uses `react-dom/server` for render
const render = staticMarkup ? renderToStaticMarkup : renderToString

// Dumps data into a `__NEXT_DATA__` document prop
const doc = <Document {...{
    __NEXT_DATA__: {
      props, // The result of getInitialProps
      page, // The rendered page
      query, // querystring parsed / passed by the user
      // ...
// packages/next/client/index.js

// Uses 'react-dom' for render
let isInitialRender = true
function renderReactElement (reactEl, domEl) {
  // The check for `.hydrate` is there to support React alternatives like preact
  if (isInitialRender && typeof ReactDOM.hydrate === 'function') {
    ReactDOM.hydrate(reactEl, domEl)
    isInitialRender = false
  } else {
    ReactDOM.render(reactEl, domEl)
  }
}

// Uses SSR's `__NEXT_DATA__` and moves it to `window`
const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent)
window.__NEXT_DATA__ = data

// Initialize next/config with the environment configuration
envConfig.setConfig({
  serverRuntimeConfig: {},
  publicRuntimeConfig: data.runtimeConfig
})

// Uses `<ErrorBoundary />` in production,
// and react-error-overlay in development.
if (process.env.NODE_ENV === 'development') {
  renderReactElement((
    <App {...appProps} />
  ), appContainer)
} else {
  // ...
  renderReactElement((
    <ErrorBoundary onError={onError}>
      <App {...appProps} />
    </ErrorBoundary>
  ), appContainer)
}

SSR with React Router

We are already doing SSR with React Router (documentation).

Code Splitting

Code Splitting with Next.js

https://nextjs.org/docs#automatic-code-splitting

Next.js does code splitting at many levels: by chunks, something called runtime and by page. It also creates a bundle for React.

_next/static
|- chunks
   |- {someNumber}.{hash}.js
   |- commons.{hash}.js

|- runtime
   |- main-{hash}.js
   |- webpack-{hash}.js

|- {hash}/pages
   |- _app.js
   |- _error.js
   |- about.js
   |- index.js

Page bundles are loaded dynamically. To decide if a module is included in the main bundle or in the page bundle, Next checks if the module is used at-least in half of your pages. If it is, then it moves into the main JavaScript bundle. If not, that module stays inside the page's bundle.

Some sneak peaks on Next's internal Webpack config:

// Prevents code splitting for SSR code
if (isServer) {
  return {
    splitChunks: false,
    minimize: false
  }
}

// The default config
const config: any = {
  runtimeChunk: {
    name: CLIENT_STATIC_FILES_RUNTIME_WEBPACK
  },
  splitChunks: {
    cacheGroups: {
      default: false,
      vendors: false
    }
  }
}

// Only enabled in production
// This logic will create a commons bundle
// with modules that are used in 50% of all pages
config.splitChunks.chunks = 'all'
config.splitChunks.cacheGroups.commons = {
  name: 'commons',
  chunks: 'all',
  minChunks: totalPages > 2 ? totalPages * 0.5 : 2
}
config.splitChunks.cacheGroups.react = {
  name: 'commons',
  chunks: 'all',
  test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/
}

let webpackConfig = {
  // Uses `cheap-module-source-map` for source maps
  devtool: dev ? 'cheap-module-source-map' : false,
  // ...
  ouptut: {
    // ...
    // Naming convention
    chunkFilename: isServer ? `${dev ? '[name]' : '[name].[contenthash]'}.js` : `static/chunks/${dev ? '[name]' : '[name].[contenthash]'}.js`,
    // ...
  },
  // ...
  plugins: [
    // Precompile react / react-dom for development, speeding up webpack
    dev && !isServer && new AutoDllPlugin({ ... }),
    // ...
  ],
}

Code Splitting with Webpack and Babel

https://reacttraining.com/react-router/web/guides/code-splitting

Webpack 4 has built-in support for dynamic imports; if you are using Babel you need to use the @babel/plugin-syntax-dynamic-import plugin. The plugin simply allows Babel to parse dynamic imports so Webpack can bundle them as a code split.

next/dynamic vs React.lazy vs react-loadable

import React from 'react'
import dynamic from 'next/dynamic'

const Component = dynamic(import('component-path'), {
  loading: () => <div>Loading...</div>
})

<Component />
import React, { lazy } from 'react'

const Component = lazy(() => import('component-path'))

<Suspense fallback={<div>Loading...</div>}>
  <Component />
</Suspense>
import Loadable from 'react-loadable';

const Component = Loadable({
  loader: () => import('component-path'),
  loading: () => <div>Loading...</div>
});

<Component />;

React is still not ready for SSR.

Next.js is prepared for SSR of dynamic components. It also provides configuration:

import dynamic from 'next/dynamic'

const DynamicComponentWithNoSSR = dynamic(() => import('../components/hello3'), {
  ssr: false
})

Alternatively, react-loadable also supports SSR. All you should need to do is include babel-plugin-import-inspector in .babelrc.

{
  "presets": ["@babel/react"],
  "plugins": [
    "@babel/plugin-syntax-dynamic-import",
    [
      "import-inspector",
      {
        "serverSideRequirePath": true
      }
    ]
  ]
}

React Router vs next/router

Next.js default file-system routing can be disabled (documentation).

// next.config.js
module.exports = {
  useFileSystemPublicRoutes: false
}

Of interest:

React Router <Link> vs next/link

Both work similarly.

import { Link } from 'react-router-dom';

<Link to="/about">About</Link>
import Link from 'next/link';

<Link href="/about"><a>About</a></Link>

Of interest:

  • React Router's has prop innerRef: function to link to anchors.
  • Next.js has prop prefetch to refetch the linked pages in the background.

Conclusion

Next.js pros

  • Abstracts many interesting things like SSR, dynamic imports, routing.
  • A small Next main bundle is around 65kb gzipped.
  • Some interesting things: supports wasm and TypeScript.

Next.js cons

  • Some of the most attractive offerings are not interesting to us: file-system routing (unless we change our file structure), static HTML export, production deployment, multi zones deployment, styled-jsx, etc.
  • It's very opinionated (for example on routing or CSS), which means we have to change a lot of code if we want to take advantage of it.
  • Customizing the routes, error pages, document DOM, etc. is possible, but result in a complex config file and a similar amount of code as a custom solution but with Next's file structure.

Tools

Webpack Bundle Analyzer

Server-Side Rendering (SSR)

Current solution

In-house solution with Express.js and a set of middlewares tailor-made to be reusable between apps. Uses Webpack 3 and Babel 6.

import express from 'express';
import ssr from 'stuart-server-middlewares/ssr';

const app = express();

app.get(
  '*',
  // ...
  // It renders the requested page and provides the data required to the client
  // to hydrate the app client-side.
  ssr()
);

Pros:

  • Works like a charm
  • Modular, most parts reusable between apps
  • Tailored to our needs

Cons:

  • Inherently complex
  • Dependencies need to be upgraded
  • Doesn't use latest technologies (code-splitting, dynamic imports, SSR optimization, TypeScript)

Current solution, upgraded

We can update our dependencies, mainly to Webpack 4 and Babel 7. Node 10 too.

Pros:

  • All of the previous ones
  • Upgraded environments. Faster and safer
  • Simpler configuration, less complexity (ideally). (I.e. "mode" flag in Webpack.)
  • Webpack 4: shared chunks automatically generated
  • Babel 7: TypeScript support!

Next.js

Popular solution for SSR, especially for React apps. Uses Webpack 4 and Babel 7.

const express = require('express')
const next = require('next')

const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })

app.prepare()
  .then(() => {
    const server = express()

    server.get('*', /* ... */)
  });

Pros:

  • All of the above
  • Externalized, oft-updated, bleeding edge
  • Server-side caching
  • Strongly integrated with React
  • Supports dynamic imports with SSR

Cons:

  • Opinionated
  • Generic, some features not needed

Code-splitting

We want to split the bundled code wisely so that the first load is as fast as it can be, including only the necessary code in the server-side render, and loading the rest when the user needs it .

Splitting by chunks of bundled js

  • vendor.js (not updated so often: React, Redux, etc.)
  • main-app.js
  • not-main-app.js

Splitting by route

  • Load page container asynchronously
  • Beware of code duplication

Splitting by components

  • Async components

Caveats:

  • SSR is tricky
  • Avoid waterfalls (async components inside async components)
  • Resource optimization in browsers: link preload/prefetch tag
  • Always serve builds with a caching CDN that caches the immutable artifacts permanently (in the case of dynamic components version mismatches)

Ideas:

  • Use folder /pages paradigm
  • What is the critical datum at any entry point?
  • Other entry points can be prefetched via <Link prefetch>
  • Use <link rel='preload'> to off-thread JavaScript prefetching and parsing.

Webpack

Two options:

  • Bundle splitting: creating more, smaller files (but loading them all on each network request anyway) for better caching. (I.e. vendor bundle.)
module.exports = {
  entry: path.resolve(__dirname, 'src/index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: /* define smart chunks */
    },
  },
};
  • Code splitting: dynamically loading code, so that users only download the code they need for the part of the site that they’re viewing.

Webpack 4 supports the import() syntax for dynamic imports (not to be confused with the import syntax).

Next.js 7

Next.js has its own automatic code splitting. Every import declared gets bundled and served with each separate page. Since we will not be using Next's routing functionality, it remains to see how it will handle the imports.

Next.js has its own Babel configuration. In order to have control over it, a .babelrc file needs to be defined, and the next/babel preset needs to be defined in it.

{
  "presets": ["next/babel"],
  "plugins": []
}

Presets should not be added in the custom .babelrc. Instead, they need to be configured inside the next/babel preset:

{
  "presets": [
    ["next/babel", {
      "preset-env": {},
      "transform-runtime": {},
      "styled-jsx": {}
    }]
  ],
  "plugins": []
}

The modules option on "preset-env" should be kept to false to keep webpack code splitting enabled.

Dynamic Imports

Specification of the import() expression to import files (i.e. modules, libraries, components, …) asynchronously (on-demand).

// synchronous
import MyModule from '../modules/myModule.js';

// asynchronous
const MyModule = await import('../modules/myModule.js');

The official TC39 proposal is at stage 3 of the TC39 process, which means it's likely that dynamic import() expressions are going to be standardized as part of ECMAScript 2018 or 2019.

Ecma TC39 Proposal

https://github.com/tc39/proposal-dynamic-import

Proposal for adding a "function-like" import() module loading syntactic form to JavaScript. It is currently in stage 3 of the TC39 process.

import('./myModule.js')
  .then(myModule => {
    console.log(myModule.default);
  });
const myModule = await import('./myModule.js');
console.log(myModule.default);

Support:

Webpack 4

[Webpack's import() syntax] conforms to the ECMAScript proposal for dynamic imports.

async function getComponent() {
 var element = document.createElement('div');
 const { default: _ } = await import(/* webpackChunkName: "lodash" */ 'lodash');

 element.innerHTML = _.join(['Hello', 'webpack'], ' ');

 return element;
}

getComponent().then(component => {
  document.body.appendChild(component);
});

Used in combination with output.chunkFilename to define non-entry files (files not included in the main build).

Next.js 7

https://nextjs.org/docs/#dynamic-import

Next.js supports TC39 dynamic import proposal for JavaScript. With that, you could import JavaScript modules (inc. React Components) dynamically and work with them.

You can think dynamic imports as another way to split your code into manageable chunks. Since Next.js supports dynamic imports with SSR, you could do amazing things with it.

import dynamic from 'next/dynamic'

const DynamicComponent = dynamic(() => import('../components/hello'))

const DynamicComponentWithCustomLoading = dynamic(
  () => import('../components/hello'),
  {
    loading: () => (<p>Loading...</p>)
  }
)

const DynamicComponentWithNoSSR = dynamic(
  () => import('../components/hello'),
  {
    ssr: false
  }
)

Note: react-loadable uses the same logic and is for React Router.

TypeScript 2.4

Dynamic import was introduced in TypeScript 2.4. The TypeScript application needs to be compiled with --module esnext so that it doesn't transpile it to a deferred require().

async function getZipFile(name: string, files: File[]): Promise<File> {
  const zipUtil = await import('./utils/create-zip-file');
  const zipContents = await zipUtil.getContentAsBlob(files);
  return new File(zipContents, name);
}

React lazy and Suspense

Since React 16.6.0 you can use the Suspense component to do code-splitting by wrapping a dynamic import in a call to React.lazy().

import React, {lazy, Suspense} from 'react';
const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <OtherComponent />
    </Suspense>
  );
}

You can also use React.lazy by itself:

import React, {lazy } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

Note: React.lazy and Suspense is not yet available for server-side rendering.

Interesting reads

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment