Achieving a perfect 100% Google Lighthouse audit score with Next and Redux

Kyle Johnson
Kyle Johnson
Aug 26 2019
100% lighthouse score

This post covers how we can build a React/NextJS app with Redux that achieves a 100% audit score with server-rendering, localisation support and can be installed as a PWA and navigated whilst offline.

next.js

next.js is my new favourite thing. Built specifically for react, NextJS lets you server render your react application with little compromise to how you would normally build your app.

Developing a React app will be pretty familiar, you'll have to switch out react-router with their built-in router, and be aware that your components will have to be executable in NodeJS (just like if you were unit testing them).

The main difference is this bit of magic which we can add to our pages:

// Calls before the page is mounted, the call will happen on the server if it's the first page we visit
static async getInitialProps({ ctx: { store } }) {
  await store.dispatch(AppActions.getWidgets());
  return {};
}

Any asynchronous tasks or fetching can occur here on our pages.

Rather than regurgitate all of the power of next, I'd recommend just stepping through their getting started guide. This post details how I added redux, sagas and achieved a 100% score on Lighthouse.

I'm bored, just send me the code.

Fine. The project is also hosted at https://nextjs-redux.kyle-ssg.now.sh/. But read on if you're interested.

1. next.js with Redux

NextJS routes

Rather than defining routes within JavaScript, routes in next are based on what's in your /pages directory. Next.js defines how pages are rendered with an App component, which we can customise by making our very own _app.js. Great, that means we can create our store and give it our root app component just like any other app.

import App, { Container } from 'next/app';
import Head from 'next/head';
import React from 'react';
import { Provider } from 'react-redux';
import createStore from '../common/store';
import withRedux from 'next-redux-wrapper';
class MyApp extends App {
    static async getInitialProps({ Component, ctx }) {
        let pageProps;
        // Ensure getInitialProps gets called on our child pages
        if (Component.getInitialProps) {
            pageProps = await Component.getInitialProps({ ctx });
        }

        return { pageProps };
    }

    render() {
        const { Component, pageProps, store } = this.props;
        return (
            <Container>
                <Provider store={store}>
                    <>
                        <Head>
                            {/*...script and meta tags*/}
                            <title>TheProject</title>
                        </Head>
                        <Header/>
                        <Component {...pageProps} />
                    </>
                </Provider>
            </Container>
        );
    }
}

export default withRedux(createStore)(MyApp);

Some of this will probably look familiar to you, the main differences being:

The outcome

Adding a simple get widgets action we can see the following differences depending on if we loaded the page from landing straight on it vs navigating to it from another page.

Client vs Server rendering

This happens because getInitialProps is called on the server during the initial page load, it knows which page to call it on based on the route.

2. Achieving a 100% Lighthouse score

Even locally, I noticed how fast everything felt. This leads me to wonder how performant I could get the page. Within chrome dev tools there's a great tool called L that rates your site based on several recognised best practices and meets the progressive web app standard.

Baseline score

Baseline lighthouse score

The baseline score was not too bad, with performance not being a problem for a redux page hitting an API.

Accessibility

Most of these items are trivial to solve and involve employing best practices such as image alt tags, input roles and aria attributes.

Appropriate colour contrast

Lighthouse colour contrast

Lighthouse is clever enough to know which of your elements are not meeting the WCAG 2 AA contrast ratio thresholds, stating that your foreground and background should have a contrast ratio of at least 4.5:1 for small text or 3:1 for large text. You can run tools such as Web AIM's contrast checker. A quick CSS change fixed this but obviously, this will mean a good amount of refactoring for content-rich sites.

Localisation

Lighthouse localisation

This one was a little more tricky. To do a good job of this I wanted the serverside render to detect the user's preferred locale and set the lang attribute as well as serve localised content. Searching around I did come across next-i18next, however, I noticed that it doesn't support serverless and it's difficult to share locale strings with react-native-localization.

I wanted something that would work with react-localization, so my approach was as follows:

    // _document.js
    static async getInitialProps(ctx) {
        const initialProps = await Document.getInitialProps(ctx);
        const locale = API.getStoredLocale(ctx.req);
        return { ...initialProps, locale };
    }
    ...
    render() {
        return (
            <html lang={this.props.locale}>
                ...
            </html>
        )
    }
// localization.js
import LocalizedStrings from 'react-localization';

const Strings = new LocalizedStrings({
    en: {
        title: 'Hello EN',
    },
    'en-US': {
        title: 'Hello US',
    },
});

export default Strings;
    // _app.js
    static async getInitialProps({ Component, ctx }) {
        let pageProps;
        const locale = API.getStoredLocale(ctx.req); // Retrieve the locale from cookie or headers
        await ctx.store.dispatch(AppActions.startup({ locale })); // Post startup action with token and locale
        ...
    }
// _app.js
render(){
        if (!initialRender) {
            initialRender = true;
            const locale = store.getState().locale;
            if (locale) {
                Strings.setLanguage(locale);
            }
        }
    ...
}
    // pages/index.js
     render() {
            return (
                <div className="container">
                    <h1>Home</h1>
                    {Strings.title}
                </div>
            );
      }

Best practices

Since the project had pretty up to date libraries and didn't do anything unruly, this already had a good score. The only thing we had to do was use http2 and SSL, which is more down to how you're hosting the application. Using Zeit covered both of these.

SEO

Thanks to nextJS you can easily add meta tags on a per-page basis, even using dynamic data from getInitialProps.

Progressive web app

Lighthouse progressive web app

PWAs make our web apps installable, combined with service workers we can serve content whilst the user is offline.

The first step was to add a simple manifest, this lets us configure how it should behave when installed.

/static/manifest.json
{
  "short_name": "Project Name",
  "name": "Project Name",
  "icons": [
    {
      "src": "/static/images/icons-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/static/images/icons-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/?source=pwa",
  "background_color": "#3367D6",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#3367D6"
}
//_app.js
<link rel="manifest" href="/static/manifest.json"/>

Offline support with service workers

Thanks to next-offline, adding service worker support was simple. Getting the service worker to work with serverless and hosted on Zeit however was a bit fiddly, we had to add a route for our server to serve the correct content header.

// now.json
{
  "version": 2,
  "routes": [
    {
      "src": "^/service-worker.js$",
      "dest": "/_next/static/service-worker.js",
      "headers": {
        "Service-Worker-Allowed": "/"
      }
    }
    ...
  ]
}

And then configure next-offline to serve the service worker from static.

next.config.js
{
    target: 'serverless',
    // next-offline options
    workboxOpts: {
        swDest: 'static/service-worker.js',

The result

As a result of this, we now have a solid base project with a 100% audit score, server-rendered, localised and can be installed and navigated whilst offline. Feel free to Clone it and hack around!