loading...

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

kylessg profile image Kyle Johnson Updated on ・6 min read

(Moved to here)

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

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:

  • In our route app, we need to make sure our pages getInitialProps functions are being called before rendering
  • Next.js provides a Head component that lets us render out any standard tags that live inside the head, this can even be done per page. This is useful for adding opengraph/meta tags/titles per page.
  • next-redux-wrapper is an out of box library that lets us use createStore.

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.

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

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 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


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:

  • 1: When the document attempts to render on the server, we want to get the preferred locale and set the lang attribute to the HTML tag. This info comes from the server, either from a cookie which we could set or by parsing the Accept-Language Header. A code snippet for how I did this can be found here.
    // _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>
        )
    }
  • 2: I define some localised strings
// localization.js
import LocalizedStrings from 'react-localization';

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

export default Strings;
  • 3: I want my app to know what the locale is in a store so that I can use that information later.
    // _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
        ...
    }
  • 4: I set the language once in my app on the initial client and server render.
// _app.js
render(){
        if (!initialRender) {
            initialRender = true;
            const locale = store.getState().locale;
            if (locale) {
                Strings.setLanguage(locale);
            }
        }
    ...
}
  • 5: In my pages, I am now free to use localised strings.
    // 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

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!

Discussion

pic
Editor guide
Collapse
sarneeh profile image
Jakub Sarnowski

Achieving a 100% score with a half-blank page is trivial. I wish someone would write up an article how to do it on a medium-large scale app.

Collapse
kylessg profile image
Kyle Johnson Author

Fair point, although I'd say trivial is a bit of an exaggeration.

I'm currently building from this on an enterprise level application tying together several APIs and a CMS and using a cdn for static resources, currently still hitting 100s and plan on using lighthouse in CI to make sure people don't break it (it's normally accessibility that breaks).

The problem I guess I'd have with writing about that (when I can / if I'm allowed to) is that at that level projects have very unique characteristics so it's hard to write a one sized fits all. I'd suggest with NextJS and webpack common chunking the main decision is picking what to SSR and caching approaches to pages.

Collapse
sarneeh profile image
Jakub Sarnowski

That's true, trivial was not the best word to use :D Also true that it's hard to write something that will suit all bigger-sized projects. But there are things that could be common to all of them and would not be so easy to fix:

For example:

  • loading images
  • loading critical css
  • reducing main-thread work

Additionally, when you're using Redux or other state-management libraries and have a massive store, it's also good to:

  • load reducers asynchronously
  • load sagas asynchronously

There are also many ways to optimize stuff thanks to NextJS support for dynamic component loading.

All those things can be very hard to do and I think that it would be nice to have an article which would show up how to handle this on some example.

Anyways, I didn't mean your article is bad or useless, it's a great write up! :) Sorry if it did sound like that!

Thread Thread
kylessg profile image
Kyle Johnson Author

Haha no offence taken I do see where you’re coming from. Agree with the points, and yeah next seems like the perfect start point to get so many optimisations.

Ok yeah so images should be behind a cdn, optimised for size and if you know dimensions in advance you can prevent the page jumping around.

Did you see much benefit in loading reducers/sagas async? The only benefit I could see is if some of those import other libs, e.g in one of my cases a socket library.

I’m quite interested in looking at github.com/dunglas/react-esi. Seems like you could do some crazy stuff for caching.

Thread Thread
sarneeh profile image
Jakub Sarnowski

Actually there's not much benefit in loading reducers/sagas, so this suggestion might be just my overengineering :) Thanks for bringing up react-esi - I didn't know about this!

Collapse
eltel profile image
Terry Mitchell

Hi Kyle, nice work though I kind of stumbled at the Now.json part as I'm hosted on Heroku (updating an existing app to PWA) - do you have some idea how I could configure the service worker on there??

Thanks for your work and time.

Terry

Collapse
nilportugues profile image
Nil Portugués Calderó

Thanks for sharing :D

Collapse
kylessg profile image
Kyle Johnson Author

No problem mate!