Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Universal React app template focused on performance best-practices.

License

Notifications You must be signed in to change notification settings

kevinfarrugia/performance-first

Repository files navigation

Archived: I am no longer maintaining this repository and archived it as I do not think it reflects the current best-practices and I am unable to keep it updated inline with the these recommendations

Build

Performance-First template

Performance-First template is a template for server-side rendering React web apps prioritizing performance best practices. The template includes critical CSS, module/nomodule, code-splitting, service workers, server-side rendering, partial hydration, granular chunking, resource hints, CSS modules, async CSS, and much more.

View Demo

Disclaimer: While this template is production-ready and is used on public websites of varying size, we recommend you also look at the more popular React frameworks such as Remix and NextJS before proceeding.

Technologies

Scope

The goal of this project is to create a reference or starting point for a fast-performing website configuration using ReactJS. I will try to keep this updated with the latest findings and research. All feedback is welcome.

lighthouse-score-home

lighthouse-score-banner-page

Disclaimer: Lighthouse scores only serve as an indication of performance best practices, not your user's experience. Performance is much more complex than a simple 0-100 score. If you want to monitor performance properly, I recommend that you look at RUM tools such as SpeedCurve, Akamai mPulse, or the web-vitals library.

Getting Started

If you don't already have Node.js, download and install Node.js >= 16.

Quick Start

  1. Install all dependencies and developer tools as listed in package.json:
npm install
  1. Bundle & run the application in development mode
npm start

Adding a new route

Performance-First template allows you to add routes dynamically or statically. A dynamic route is added through an external service, such as an API; while a static route is declared directly in the codebase, usually in the form of a JSON file.

Router service

The default Router included in the template retrieves the routes from en.json. It is responsible for caching routes using a stale-while-revalidate strategy and checking if a given route is valid.

[
  {
    "key": "/",
    "value": "home"
  },
  {
    "key": "/about",
    "value": "about"
  },
  {
    "key": "/:path",
    "value": "defaultpage"
  }
]

The en.json file contains three different types of routes and a corresponding path for each one. The home and about route use exact URLs, while the defaultpage supports a wildcard :path. The more specific routes should be declared at the top of the file, while the more generic ones should be placed at the bottom.

const toRoutes = (routes) => {
  if (routes) {
    return routes.map((n) => ({
      path: n.key,
      name: n.value.toLowerCase(),
    }));
  }

  return [];
};

The en.json file is then transformed using adapter.js to create an array of Route objects that are understood by our router. This means that you could plug the Performance-First template into any API by changing the toRoutes method to match your API's response.

react-router

Routing is configured using react-router v6 on both the client and server-side.

DefaultPage

Adding a default page requires zero-config. The route will be picked up by the /:path wildcard, assigning it a value of defaultpage. This will render the DefaultPage component which renders the page's content and updates the <meta> elements.

const getPage = async ({ path }) =>
  fetch(
    new URL(
      `${CMS_URL}/api/Page/${path}`
    )
  );

The application makes a request to getPage using the path as an argument. The getPage should call your API endpoint which returns the page data while accepting the path. The template uses a mock API service by downloading the JSON files directly from GitHub.

<Page path={pathname} onGetPage={getPage} scrollToTop>
  {({ page: { title, html, banner }, isReady: isPageReady }) => {
    {/* ... */}
  }}
</Page>

If the user is navigating to the page using a soft navigation, i.e. within the client-side router, then the <Page> component is responsible for fetching and updating the Redux store.

Customizing the DefaultPage

You can fully customize the appearance & structure of the DefaultPage component by modifying the defaultPage component and the corresponding styles.scss.

Custom page

If you want to create a route that serves a custom page - meaning a page that has a different structure from the other pages - then you can create a new component and configure that page using the AppRouter config.

const getRouteConfig = (name) => {
  switch (name) {
    case "home":
      return {
        Component: Home,
        fetchData: [getHomeSSR],
      };
    case "about":
      return {
        Component: About,
        fetchData: [getAboutSSR],
      };
    case "defaultpage":
      return {
        Component: DefaultPage,
        Fallback: DefaultPageSkeleton,
        fetchData: [getDefaultPageSSR],
      };
    case "blogpage":
      return {
        Component: BlogPage,
        fetchData: [getBlogPageSSR],
      };
  }
};

The getRouteConfig receives the name of the route as defined in the toRoutes adapter and returns an object containing the Component to render, the Fallback component until the Component is fetched (if using loadable) and a fetchData array.

fetchData

The fetchData array is a list of Promises which are executed and awaited when the route is requested from the server-side application.

As an example, if the user lands on the "home" route, the server will execute getHomeSSR and await it before rendering the HTML.

Each function in the fetchData array will receive the following arguments:

fn(store, options)

The store refers to the Redux store while options include the following:

Name Description
path The request path (e.g: /home).
match The PathMatch object for that request.
query Query string params.
url Request URL string. (e.g: /home?a=b).
route The matching route as stored in the AppRouter reducer.
const getHomeSSR = (store, { path }) =>
  store.dispatch(
    getHomePage({
      path,
    })
  );

This allows you to populate the Redux store with data for that specific route.

case "mypage":
  return {
    Component: MyPage,
    fetchData: [getHeaderSSR, getNavigationMenuSSR, getBannerSSR, getMyPageSSR],
  };

As you are not limited to a single fetchData function, you may combine several requests that are needed to render a page.

[
  {
    "key": "/",
    "value": "home"
  },
  {
    "key": "/about",
    "value": "about"
  },
  {
    "key": "blog/:path",
    "value": "blogpage"
  },
  {
    "key": "/:path",
    "value": "defaultpage"
  }
]

Similar to the DefaultPage, you can use a wildcard to serve a dynamic number of similar pages, such as the blogpage above.

Scripts

Flag Description
build Builds the project. Accepts --release, --analyze flags
build-stats Build the project with production configuration and launches Webpack Bundle Analyzer
serve Runs the Express server and serves the output from the build folder
start Launches Webpack compiler in watch mode (via webpack-middleware) and runs the development server, including HMR and BrowserSync. Accepts --release, --hot flags

Arguments

To pass arguments to the NPM script, you are required to use double dashes, ex:

npm run build -- --release

Deploying to production

For the production environment, you are expected to run the following scripts

npm run build -- --release --verbose

You should then navigate to the build output folder and run the server using node directly instead of through npm:

cd ./build
node server.js

Docker

If you are using Docker, the template includes a default Dockerfile. The Dockerfile compiles and runs a production build on a lightweight Alpine Linux environment using Node 16.

Folder Structure

.
β”œβ”€β”€ .github/workflows          # GitHub actions
β”œβ”€β”€ .husky/                    # Husky pre-commit hooks
β”œβ”€β”€ api/                       # Mock API JSON service
β”œβ”€β”€ build/                     # Compiled output
β”œβ”€β”€ public/                    # Static files which are copied into the /build/public folder
β”œβ”€β”€ scripts/                   # Build automation scripts and utilities
β”‚   β”œβ”€β”€ lib/                   # Utilities for build scripts
β”‚   β”œβ”€β”€ build.js               # Builds the project from source to output (build) folder
β”‚   β”œβ”€β”€ bundle.js              # Bundles the web resources into package(s) through Webpack
β”‚   β”œβ”€β”€ clean.js               # Cleans up the output (build) folder
β”‚   β”œβ”€β”€ copy.js                # Copies static files to output (build) folder
β”‚   β”œβ”€β”€ run.js                 # Helper function for running build automation tasks
β”‚   β”œβ”€β”€ runServer.js           # Launches (or restarts) Node.js server
β”‚   β”œβ”€β”€ start.js               # Launches the development web server with HMR
β”‚   └── webpack.config.js      # Configurations for client-side and server-side bundles
β”œβ”€β”€ src/                       # The source code of the application
β”‚   β”œβ”€β”€ js/                    # JavaScript application code
β”‚   β”‚   β”œβ”€β”€ components         # React components
β”‚   β”‚   β”œβ”€β”€ hooks              # React hooks
β”‚   β”‚   β”œβ”€β”€ util               # Utilities & helper functions
β”‚   β”‚   β”œβ”€β”€ reducerRegistry.js # ReducerRegistry class definition
β”‚   β”‚   β”œβ”€β”€ reducers.js        # Globally available reducers
β”‚   β”‚   β”œβ”€β”€ store.js           # Store configuration and overrides to connect ReducerRegistry
β”‚   β”œβ”€β”€ scss/                  # Global SCSS files & fonts
β”‚   β”œβ”€β”€ service/               # Mock service-layer & API integrations
β”‚   β”œβ”€β”€ templates/             # Handlebar templates used to render HTML content in Express
β”‚   β”œβ”€β”€ client.js              # Entry point for client-side bundle
β”‚   β”œβ”€β”€ main.js                # The entry point for the application. This is imported by both client.js and server.js
β”‚   β”œβ”€β”€ polyfills.js           # The entry point for the legacy bundle.
β”‚   β”œβ”€β”€ render.js              # Handles SSR for React application
β”‚   β”œβ”€β”€ server.js              # Entry point for server-side bundle
β”‚   β”œβ”€β”€ sw.js                  # Service Worker used by WorkboxPlugin.InjectManifest
β”œβ”€β”€ babel.config.js            # @babel/eslint-parser configuration file
β”œβ”€β”€ Dockerfile                 # Commands for building a Docker image for production
β”œβ”€β”€ package.json               # The list of 3rd party libraries and utilities
└── package-lock.json          # Fixed versions of all the dependencies
└── postcss.config.js          # PostCSS plugins & options

Naming Conventions

The naming conventions that are followed throughout the template:

  • Folders begin with an uppercase letter.
  • Files begin with a lowercase letter.
  • Folders contain an index.js file that exports the functions and components for that module.

Hot Module Reloading

The project allows for Hot Module Reloading using webpack-hot-middleware. This enables HMR for JSX, S/CSS, and NodeJS.

Loadable Components

Loadable Components are the de-facto standard for lazy loading on an SSR React application. Loadable Components reads the stats files generated by Webpack and @loadable/webpack-plugin to split your bundle into sizeable chunks.

The Performance-First template uses component-based code-splitting by default, however, you may override this by using webpackChunkName.

import(/* webpackChunkName: "commonFeature" */ './featureA')
import(/* webpackChunkName: "commonFeature" */ './featureB')
import(/* webpackChunkName: "featureC" */ './featureC')

The above example will result in two chunks, one containing ./featureA and ./featureB and the other containing ./featureC.

Critical CSS

Critical or inline CSS is determined using loadable-components. The CSS required to render all the components on the server route is automatically inlined in the <head>.

While using HMR (npm run start), inlined CSS is disabled as mini-css-extract-plugin does not support HMR on the server.

Read more about inlining critical CSS and its performance benefits.

ReducerRegistry

The ReducerRegistry is a singleton class that exposes a register method, allowing consumers to dynamically attach reducers to the store and improve code-splitting.

import reducerRegistry from "../../reducerRegistry";

reducerRegistry.register(REDUCER_NAME, reducer);

The register method may be called using the above syntax and should be called before calling any actions for that reducer, including server-side requests. As a rule-of-thumb, I recommend placing it in the reducer.js file and the fetchData SSR methods for server-side requests.

The configureDynamicStore function combines the Redux Toolkit's configureDynamicStore with a change listener to automatically update the reducers in the store.

The store is configured on both the server application and the client application. The server does not have an initial state and should reset all reducers between requests, while the client will use the window.__PRELOADED_STATE__ as the initial state.

Tip: If you find yourself registering multiple reducers in the same component (i.e. calling reducerRegistry.register more than once in the same component), then most likely you would benefit from separating the component into two smaller components.

Templates

Performance-First template generates all HTML using the server-side rendered data by passing it to the HTML through Handlebars templates.

Name Description
htmlattributes Attributes to be appended to the <html>. tag
inlineCss Critical CSS added to the <head>.
css <link> elements for CSS stylesheets added to the <head>.
head Any additional elements to be added to the end of the <head> tag. By default, these are generated using react-helmet and include <title>, <meta> and <link> elements.
bodyattributes Attributes to be appended to the <body>
html HTML content for the current route.
scripts <script> elements added to the end of the <body>.
preloadedState Redux state to be hydrated by the client-side.

Module / nomodule

Script elements use the module / nomodule pattern to serve modern JavaScript to modern devices while not compromising legacy browsers.

const scripts = renderToString(
  <Scripts scripts={scriptElements} legacyScripts={legacyScriptElements} />
);

The HTML for the scripts is generated using the <Scripts> component, which receives scripts and legacyScripts.

<script type="module" src="/webpack.mjs"></script>
<script type="module" src="/client.mjs"></script>
<script type="module" src="/Home.mjs"></script>
<script type="nomodule" src="/webpack.js" defer></script>
<script type="nomodule" src="/client.js" defer></script>
<script type="nomodule" src="/Home.js" defer></script>

The output HTML should include both .mjs and .js files. A modern browser will only download the .mjs files, while a legacy browser would only download the .js files.

State Management

Redux is a state container used together with React. The template uses Redux Toolkit and follows the standards and practices documented by Redux Toolkit.

Suggested guidelines

Below are some recommendations I like to follow when working on a website. These may not all apply equally to every project, so I use them as guidelines and then test to see how they perform.

Fonts

  • Font files are preloaded. Only preload the woff2 and a maximum of two font files - usually bold and normal.
  • Prefer font-display:swap. This may cause a FOUT (flash of unstyled text). If unsightly, remove (defaults to auto).
  • Use glyphhanger to subset your fonts and remove unused glyphs. May result in 50% smaller font files.

cache-control headers

  • Use a CDN.
  • Cache static assets (images, scripts, stylesheets) for one year and use a content hash.
  • Cache the HTML page responsibly.

Images

FAQs

There is a flash of unstyled content (FOUC). Is this intentional?

The FOUC originates from style-loader which adds the CSS files to the DOM using JavaScript. This is not the case on a production build as style-loader is replaced by mini-css-extract-plugin.

During development, I cannot see legacy bundles.

Legacy bundles are only compiled when serving a production build so as not to slow down the Webpack compile-time and feedback loop.

Error: loadable: cannot find MyPage in stats

This error occurs when you have added a new dynamically imported bundle but have not yet compiled. If the issue doesn't resolve itself automatically, try re-running npm start.

Can I use the mock API in production?

The API could be a simple hard-coded JSON file, so as long as the data is publicly accessible you can continue using the mock API.

GitHub Actions

The template includes a GitHub action build.yml that builds the Docker image when code is merged to the main branch or a pull request to main is created or updated.

This could be extended to deploy the application to your development or production environment. As an example, deploying to AWS EB could be done by adding the following snippet at the end of build.yml:

      - name: EB Deploy
        uses: hmanzur/actions-aws-eb@v1.0.0
        with:
          command: "deploy ${{ secrets.aws_env_prod }} --timeout 20"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}
          AWS_DEFAULT_REGION: "eu-central-1"

Webpack v4

If you are looking for a Webpack v4 compatible version, please see branch webpack-4, but be aware that this branch is no longer maintained.

Inspiration

The project was inspired by React Starter Kit and Create React App, although I have altered many bits to better suit my personal preferences, which usually center around simplicity or performance. As a result, the code is opinionated while following the best standards and practices.

Contributing

Anyone and everyone are welcome to contribute to this project and leave feedback. Please take a moment to review the guidelines for contributing.

License

Copyright Β© 2020-present Spiffing Ltd. This source code is licensed under the MIT license found in the LICENSE file.


Please feel free to get in touch with me. Kevin Farrugia (@imkevdev)