Tutorial: SSR Split Testing and Analytics with React, Redux, and Next.js

9,480 reads

@ patrickleet Patrick Lee Scott Read my story at https://patscott.io/

“The Lean Startup” by Eric Reis prescribed some medication for our venture’s success: Build, Measure, Learn, repeat. This post focuses on the “measure” portion.

Split Testing and Analytics are not features, they are requirements.

How can you know if anything is working if you don’t know what users are even doing? How can you know if an approach is more or less successful if you are not measuring?

“When launching a new funnel, you don’t necessarily expect it to work right away — you are buying data.” — Ruda Krishna

Not only are Split Testing and Analytics requirements; they are also equalizers. They can help your team eliminate “HiPPOs” or the Highest-Paid Person’s Opinion.

This is when despite evidence for a solution being strong, the highest paid person’s opinion is still what ultimately gets implemented… cause they said so and they make more than you. Clearly a higher “band” means higher intelligence… 😜

I prefer a more scientific approach: Everything is a hypothesis until the data says otherwise.

To this end, I’ve written a lot of split tests, and, I think that many people really over complicate the matter.

I want to show how simple it is to add custom SSR split testing using Redux and Next.js.

In this tutorial we will start with an empty Node.js project and walk through the process of building simple split-testing functionality with Next.js, Redux, and seamless analytics by creating custom Redux analytics middleware.

This serves dual-purposes. As an introduction to Redux and Next.js, as well as how to build an actually useful feature using them.

For those of you who are unfamiliar with the technologies:

Redux: Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.

React: A JavaScript library for building user interfaces

Next.js: Framework for server-rendered or statically-exported React apps

NOTE: This was written using next@5 — using next@6 break the tutorial. The concepts from the tutorial are the important part, and can be applied in any framework or language. I’ve added version numbers in the install commands so you can still follow along.

1. We’ll start with a brand new Node.js project

mkdir split-test

cd split-test

echo "node_modules

.next" | tee .gitignore

npm init

2. Install and configure Next.js

npm install --save next@5 react@16 react-dom@16

# and open up your code editor. I'm using VSCode.

code .

And to finish setting up next, we need to add the following to package.json

"scripts": {

"dev": "next",

"build": "next build",

"start": "next start"

}

Next, we need an index page. From the root directory, create a folder called pages and in it create a file index.js

./pages/index.js

export default () => <div>Welcome to next.js!</div>

If you’re not familiar with Next.js, it is an opinionated framework built around Webpack. Everything in the pages directory is a new entrypoint which is code-split .

You can run your app now using npm run dev . Hot Code reloading is enabled by default. Check out your progress by visiting localhost:3000!

But what if this page converted better if it said “Welcome to MY next.js”? Let’s proceed.

3. Configure Redux

Redux is going to be managing the state of the application, including which experiments are currently active.

Install Redux

npm i --save redux@3 react-redux@5 next-redux-wrapper@1

Next, let’s create a module that will initialize Redux. Let’s call it initRedux.js and store it in a new folder lib .

./lib/initRedux.js

import {

createStore,

combineReducers,

applyMiddleware,

compose

} from 'redux'

let reducers = {}

let reduxStore = null

// The following checks if the Redux DevTools extension

// is available in your browser, and activates it if so.

// Otherwise, it executes a no-op function

let devtools = f => f

if (process.browser && window.__REDUX_DEVTOOLS_EXTENSION__) {

devtools = window.__REDUX_DEVTOOLS_EXTENSION__()

}

function create (initialState = {}) {

return createStore(

combineReducers({ // Setup reducers

...reducers

}),

initialState, // Hydrate the store with server-side data

devtools

)

}

export default function initStore (initialState) {

// Make sure to create a new store for every server-side request so that data

// isn't shared between connections (which would be bad)

if (!process.browser) {

return create(initialState)

}



// Reuse store on the client-side

if (!reduxStore) {

reduxStore = create(initialState)

}

return reduxStore

}

In the above file we exported a default function, initStore , that will create the store with a provided initialState . Because Next.js is isomorphic this means code will run on the client side as well as the server side. In initStore we are also checking whether or not the process is running in the browser. If it is, we can reuse reduxStore that was created on the server.

In either case, create will be called next. This creates a new redux store with our reducers, the initialState, and Redux DevTools for a better Development experiment.

Next, let’s “wire up” Redux to our index page.

./pages/index.js

import initStore from '../lib/initRedux'

import withRedux from 'next-redux-wrapper'

const Index = () => <div>Welcome to next.js!</div>

const mapStateToProps = () => ({})

const mapDispatchToProps = () => ({})

export default withRedux(initStore, mapStateToProps, mapDispatchToProps)(Index)

We’ve modified ./pages/index.js by wrapping the component with withRedux . withRedux takes a function that return a redux store as it’s first argument, and two additional functions that return an object of props that will be available to the component. We will fill these in soon. For now, we have successfully wired up Redux. Visiting localhost:3000, will show the Redux DevTools chrome extension enabled. Install it to check if you have not already.

4. Experiments Reducer

Reducers are where your applications logic is stored. Actions come in, and the reducer determines how this action changes the state.

Let’s start by creating some new folders in lib, called redux and redux/reducers , and define a file experiments.js in the new reducers folder.

./lib/redux/reducers/experiments.js

export default (state = {

active: {}

}, { type, payload }) => {

switch (type) {

case 'START_EXPERIMENT':

let { name, variant } = payload

let active = Object.assign({}, state.active)

active[name] = variant

return {

active

}

default:

return state

}

}

export function startExperiment ({name, variant}) {

return {

type: 'START_EXPERIMENT',

payload: {

name,

variant

}

}

}

Our entire reducer is pretty simple — it exports a reducer with an initialState of { active: {} } . All our application really needs to know about is which experiments are active, so this should be enough.

Next we defined a case 'START_EXPERIMENT' for our reducer’s switch statement. This will add a new key to the active state we defined with the experiment’s name, and the value will be the active variant.

Lastly, we export an action startExperiment . This takes a configuration object, and expects an experiment name , and which variant is active.

./lib/redux/reducers/index.js

import experiments from './experiments'

export default {

experiments

}

Any time we have a new reducer, we can just add it to this export. And we just need to wire it up.

./lib/initRedux.js

In ./lib/initRedux.js replace the line:

let reducers = {}

with:

import reducers from './redux/reducers'

Redux is now “wired up” with our reducers.

Next we will need to start the experiment on the server side by making use of Next.js’s getInitialProps .

5. dispatch from getInitialProps

Below the const Index = ... line, let’s add a getInitialProps function. When we used withRedux to wrap our component, this made the Redux store accessible in the context object passed to getInitialProps . We’ll need to call the action we defined in our reducer, so import that at the top as well.

./pages/index.js

import { startExperiment } from '../lib/redux/reducers/experiments'

// ...

/* context: {req, res, query, isServer, store} */

Index.getInitialProps = ({store}) => {

const dispatchStartExperiment = ({name, variant}) => {

store.dispatch(startExperiment({

name,

variant

}))

}

}

// ...

Ok, we’ve defined a function that dispatches a startExperiment action, and takes in the experiment name and variant as parameters.

Next we need experiments. We will naturally do so, with JavaScript.

6. Create a new Experiment()

We will create an Experiment class, that extends EventEmitter as an easy way to hook in to each experiment’s state changing.

First the Experiment class.

In it a selectVariant algorithm, which given a list of variants, and an id, will always select the same variant. Meaning all you need to do to ensure a user always get’s the same experiment, is pass in the same id. You can do this by storing their userId, or a hash of it, in a cookie. I borrowed this algorithm from react-ab-test.

I think that using React components to maintain state of experiments is over-complicated, and redux is a better fit for the task, but “props” for the algorithm John Wehr. 👏

./lib/Experiment.js

import { EventEmitter } from 'events'

import crc32 from 'fbjs/lib/crc32'

export default class Experiment extends EventEmitter {

constructor ({

name,

variants,

userId

}) {

super()

this.name = name

this.variants = variants

this.userId = userId

}

selectVariant (userId) {

/*

Choosing a weighted variant:

For C, A, B with weights 2, 4, 8

variants = A, B, C

weights = 4, 8, 2

weightSum = 14

weightedIndex = 9

AAAABBBBBBBBCC

========^

Select B

*/

// Sorted array of the variant names, example: ["A", "B", "C"]

const variants = Object.keys(this.variants).sort()

// Array of the variant weights, also sorted by variant name. For example, if

// variant C had weight 2, variant A had weight 4, and variant B had weight 8

// return [4, 8, 2] to correspond with ["A", "B", "C"]

const weights = variants.reduce((weights, variant) => {

weights.push(this.variants[variant].weight)

return weights

}, [])

// Sum the weights

const weightSum = weights.reduce((a, b) => {

return a + b

}, 0)

// A random number between 0 and weightSum

let weightedIndex = typeof userId === 'string' ? Math.abs(crc32(userId) % weightSum) : Math.floor(Math.random() * weightSum)

// Iterate through the sorted weights, and deduct each from the weightedIndex.

// If weightedIndex drops < 0, select the variant. If weightedIndex does not

// drop < 0, default to the last variant in the array that is initially assigned.

let selectedVariant = variants[variants.length - 1]

for (let index = 0; index < weights.length; index++) {

weightedIndex -= weights[index]

if (weightedIndex < 0) {

selectedVariant = variants[index]

break

}

}

return selectedVariant

}

start ({ userId }) {

userId = userId || this.userId

let variant = this.selectVariant(userId)

this.emit('variant.selected', { name: this.name, variant })

}

}

And let’s use it to create an Experiment!

./experiments/headerText.js

import Experiment from '../lib/Experiment'

const headerTextExperiment = new Experiment({

name: 'Header Text',

variants: {

control: {

weight: 50,

displayName: 'control'

},

mine: {

weight: 50,

displayName: 'mine'

}

}

})

export default headerTextExperiment

In the experiment class we’ve defined, we simply need to pass in an object with some initialization options: 1) The name of the experiment, and 2) The variants and their respective weights. We have two variants in this example, split 50/50.

7. Activating the Experiment

We want to activate the experiment on ./pages/index.js, so let’s import it, and activate it in getInitialProps. As I mentioned earlier, we’ll want to pass in a userId if it exists, so let’s just assume that is available in the cookies for demonstration purposes.

npm install --save next-cookies@1

./pages/index.js

import initStore from '../lib/initRedux'

import withRedux from 'next-redux-wrapper'

import { startExperiment } from '../lib/redux/reducers/experiments'

import headerTextExperiment from '../experiments/headerText'

import cookies from 'next-cookies'

const Index = ({experiments}) => (

<div>

{ experiments.active[headerTextExperiment.name] === 'control' ? "Welcome to Next.js!" : null }

{ experiments.active[headerTextExperiment.name] === 'mine' ? "Welcome to MY Next.js!" : null }

</div>

)

/* context: {req, res, query, isServer, store} */

Index.getInitialProps = (ctx) => {

const { store } = ctx

const dispatchStartExperiment = ({name, variant}) => {

console.log('starting experiment')

store.dispatch(startExperiment({

name,

variant

}))

}

const activeExperiments = [

headerTextExperiment

]

headerTextExperiment.once('variant.selected', dispatchStartExperiment)

let { userId } = cookies(ctx)

activeExperiments.forEach((experiment) => {

experiment.start({userId})

})

}

const mapStateToProps = ({ experiments }) => ({

experiments

})

const mapDispatchToProps = () => ({})

export default withRedux(initStore, mapStateToProps, mapDispatchToProps)(Index)

I’ve bolded the changes for clarity. First, we import headerTextExperiment and cookies . cookies requires next’s context object, so a quick refactor to getInitialProps to expose it at the top. Then, we create an array of experiments to activate, and use a forEach to start each one, passing in a userId if available.

The experiment upon starting, will select a variant, and emit an event: 'variant.selected' . When that occurs, we will dispatch an action to redux which will update the global state. When the state changes, mapStateToProps will fire, giving our component access via it’s props to which experiments are active.

If the experiment’s variant “control” is active, we show the original text, and if the variant “mine” is active, we show the alternative text.

8. Tracking events with Analytics middleware

As we are using redux to change the state of our application, we can hook into this, and emit analytics events when redux actions occur. To do this we will create a custom analytics middleware. I’ll show how to integrate with google analytics as well as facebook analytics. You can use your imagination for other integrations. I especially like Mixpanel a lot for this type of analytics.

./lib/analytics.js

export const track = ({event, value}) => {

console.log('track', event, {

value

})

if (process.browser) {

window.ga && window.ga('send', 'event', {

eventCategory: event,

eventLabel: value

})

window.fbq && window.fbq('track', event, {

value

})

}

}

./lib/redux/middleware/analytics.js

import { track } from '../../analytics'

export default ({ dispatch, getState }) => next => action => {

const {

analytics

} = action.meta || {}

next(action)

if (analytics) {

track(analytics)

}

}

./lib/initRedux.js

import analyticsMiddleware from './redux/middleware/analytics'

// ...

function create (initialState = {}) {

return createStore(

combineReducers({ // Setup reducers

...reducers

}),

initialState, // Hydrate the store with server-side data

compose(

applyMiddleware(

analyticsMiddleware

),

devtools

)

)

}

We use compose and applyMiddleware in order to use multiple middleware as well as the Redux DevTools.

Now we can simply add a meta key to our redux actions to track them when they occur.

./lib/redux/reducers/experiments.js

// ...

export function startExperiment ({name, variant}) {

return {

type: 'START_EXPERIMENT',

payload: {

name,

variant

},

meta: {

analytics: {

event: `${name} Experiment Played`,

value: variant

}

}

}

}

Now every time startExperiment is dispatched, an event “NAME Experiment Played” will be sent to your analytics endpoints with the selected variant.

9. Tracking other events

As designed, however, this action DOES NOT occur on the client. Only the server.

Let’s add a special exception to also track this event when the component mounts on the client as well, as we’ll want this data. There are a lot of good reasons for wanting analytics on the server and the client. Such as seeing how many people requested a page, but dropped off before it loaded for whatever reason.

We need some lifecycle events, so this results in some refactoring to make Index extend React’s Component, and moving getInitialProps inside as well.

./pages/index.js

// ...

import { Component } from 'react'

import { track } from '../lib/analytics'

class Index extends Component {

static getInitialProps (ctx) {

/* ctx: {req, res, query, isServer, store} */

const { store } = ctx

const dispatchStartExperiment = ({name, variant}) => {

store.dispatch(startExperiment({

name,

variant

}))

}

const activeExperiments = [

headerTextExperiment

]



headerTextExperiment.once('variant.selected', dispatchStartExperiment)



let { userId } = cookies(ctx)

activeExperiments.forEach((experiment) => {

experiment.start({userId})

})

}

componentDidMount () {

const { experiments } = this.props

if (experiments.active[headerTextExperiment.name]) {

// startExperiment just returns the redux action object

// we can make use of this to look up the analytics

// event name and value

let analytics = startExperiment({

name: headerTextExperiment.name,

variant: experiments.active[headerTextExperiment.name]

}).meta.analytics

track({

event: analytics.event,

value: analytics.value

})

}

}

render () {

const { experiments } = this.props

return (

<div>

{ experiments.active[headerTextExperiment.name] === 'control' ? "Welcome to Next.js!" : null }

{ experiments.active[headerTextExperiment.name] === 'mine' ? "Welcome to MY Next.js!" : null }

</div>

)

}

}

// ...

The new componentDidMount function only runs on the client side. We are simply calling track with the same values from the redux action object if an experiment is active.

10. Include 3rd Party Analytics libraries

We can simply grab the javascript out of any provided snippet’s <script> tags, and store them as pure js. Next we will make use of next/head to insert them using React’s dangerouslySetInnerHTML API.

./pages/index.js

import ga from '../lib/analytics/ga'

import fb from '../lib/analytics/fb'

import Head from 'next/head'

class Index extends Component {

// ...

render () {

const { experiments } = this.props

return (

<div>

<Head>

<title>SSR Split Tests</title>

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

<script dangerouslySetInnerHTML={{__html: ga}} />

<script dangerouslySetInnerHTML={{__html: fb}} />

</Head>

{ experiments.active[headerTextExperiment.name] === 'control' ? "Welcome to Next.js!" : null }

{ experiments.active[headerTextExperiment.name] === 'mine' ? "Welcome to MY Next.js!" : null }

</div>

)

}

}

// ...

./lib/analytics/fb.js

export default `

if(typeof fbq === 'undefined') {

!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?

n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;

n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;

t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,

document,'script','https://connect.facebook.net/en_US/fbevents.js');

fbq('init', 'YOUR FB ID GOES HERE');

fbq('track', 'PageView');

} else {

fbq('track', 'PageView');

}

`

./lib/analytics/ga.js

export default `

(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){

(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),

m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)

})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');

ga('create', 'YOUR GA ID GOES HERE', 'auto');

ga('send', 'pageview');

`

11. It helps if there is a goal

Additionally, we need a goal. Right now, we just know when an experiment is viewed.

I originally made all of this for a Landing Page, so let’s stick with that example. So our goal for this page is for a user to enter their email address and submit it!

When that happens, we want to store their email in our redux store, and say thank you. This also demonstrates the untouched mapDispatchToProps and using multiple reducers.

Seems how we are tracking leads, let’s make a lead reducer.

./lib/redux/reducers/lead.js

export default (state = {

email: null

}, { type, payload }) => {

switch (type) {

case 'SIGNUP_LEAD':

let { email } = payload

return {

...state,

email

}

default:

return state

}

}

export function signupLead ({email}) {

return {

type: 'SIGNUP_LEAD',

payload: {

email

},

meta: {

analytics: {

event: `Signed Up`,

value: email

}

}

}

}

./lib/redux/reducers/index.js

import experiments from './experiments'

import lead from './lead'

export default {

experiments,

lead

}

Next we need to a new component to container the SignUp Form, and show that on the index page.

./components/SignUpForm.js

export default function SignUpForm () {

function submit(e) {

e.preventDefault()

let email = e.target.elements.email.value

if (email) {

alert(email)

} else {

alert("Email is Required")

}

}

return (

<form onSubmit={submit}>

<input name="email" type="email" placeholder="Enter your email..." />

<button>Submit</button>

</form>

)

}

./pages/index.js

import SignUpForm from '../components/SignUpForm'

// ...

render () {

const { experiments } = this.props

return (

<div>

<Head>

<title>SSR Split Tests</title>

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

<script dangerouslySetInnerHTML={{__html: ga}} />

<script dangerouslySetInnerHTML={{__html: fb}} />

</Head>

{ experiments.active[headerTextExperiment.name] === 'control' ? <h1>Welcome to Next.js!</h1> : null }

{ experiments.active[headerTextExperiment.name] === 'mine' ? <h1>Welcome to MY Next.js!</h1> : null }

<SignUpForm/>

</div>

)

}

// ...

12. Connect redux to form

Our form needs access to a redux action we’ve defined, as well as the lead state from our store. We can use mapDispatchToProps and mapStateToProps to provide access.

./pages/index.js

// ...

import { signupLead} from '../lib/redux/reducers/lead'

class Index extends Component {

// ...

render () {

const { experiments, lead, signupLead } = this.props

return (

<div>

<Head>

<title>SSR Split Tests</title>

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

<script dangerouslySetInnerHTML={{__html: ga}} />

<script dangerouslySetInnerHTML={{__html: fb}} />

</Head>

{ experiments.active[headerTextExperiment.name] === 'control' ? <h1>Welcome to Next.js!</h1> : null }

{ experiments.active[headerTextExperiment.name] === 'mine' ? <h1>Welcome to MY Next.js!</h1> : null }

<SignUpForm lead={lead} signup={signupLead}/>

</div>

)

}

}

const mapStateToProps = ({ experiments, lead }) => ({

experiments,

lead

})

const mapDispatchToProps = (dispatch, ownProps) => ({

signupLead: ({email}) => {

dispatch(signupLead({email}))

}

})

// ...

In the above we are simply importing the new action, and then mapping it to props with dispatch in mapDispatchToProps . We are also modifying mapStateToProps to have access to the lead state.

Finally, let’s finish the form using our new props!

./components/SignUpForm.js

export default function SignUpForm ({lead, signup}) {

function submit(e) {

e.preventDefault()

let email = e.target.elements.email.value

if (email) {

signup({email})

} else {

alert("Email is Required")

}

}

return (

<div>

{typeof lead.email === 'string' && lead.email.length > 0 ?

<p>Hello {lead.email}</p>

:

<form onSubmit={submit}>

<input name="email" type="email" placeholder="Enter your email..." />

<button>Submit</button>

</form>

}

</div>

)

}

Now, when pressing submit, the input box will switch to say Hello {lead.email} and track the event “Signed Up” with the email as the value.

Now in your funnel analytics you can make two queries:

Starting with “Header Text Experiment Played” with value “control” Starting with “Header Text Experiment Played” with value “mine”

You’ll be able to easily see the percentage of users who made it through each step for each experiment.

Conclusion 🎉

There you have it, SSR split tests with (mostly) automatic analytics (so long as you continue to use redux)!

Thanks for reading! Until next time! If you found this useful, please clap and share because it will help me reach more people! :)

You can find all of the code in this GitHub repository. Feel free to use it as a boilerplate!

Best,

Patrick Scott

—

Interested in hearing MY DevOps Journey, WITHOUT useless AWS Certifications? Read it now on HackerNoon.

I am available for consulting — send me a message on Twitter or LinkedIn. Please mention you saw my article! Don’t be shy!

Want to learn how to build a custom analytics backend with microservices? In my upcoming course “Microservice Driven” I am doing just that. Starting with this example you will learn how to build a microservices backend for tracking analytics and leads, and run it all in production using Docker Swarm. Sign up now!

Tags