Adding a Page to the PWA

This guide walks you through the process of defining a new page in your PWA. In this guide you'll learn how to:

  • Define a route
  • Create a handler
  • Create a model
  • Create a view
  • Add styling

In the sections below, we'll create a simple "About" page that displays details about the site and company behind it.

Step 1: Define a Route

The first step to adding a new page to the PWA is defining the page's URL by creating a route. In React Storefront, routes are defined in src/routes.js. Currently there are routes defined for category, subcategory and product pages. Let's add a route for /about above the fallback route:

.get('/about', 
  fromClient({ page: 'About' }),
  fromServer('./about/about-handler')
)

Here we specify that when the user navigates to /about, we'll immediately set the page property of the app model to "About" while fetching data from the server by calling src/about/about-handler.js. When the response is received, the data contained there in will be applied to the app model. This file doesn't exist yet, but we'll create it shortly.

The routes.js file should now look like:

import { Router, fromClient, fromServer, cache, proxyUpstream } from 'react-storefront/router'

const cacheHandler = cache({ server: { maxAgeSeconds: 300 }, client: true }) // cache responses in varnish for 5 minutes

export default new Router()
  .get('/', 
    cacheHandler,
    fromClient({ page: 'Home' }),
    fromServer('./home/home-handler')
  )
  .get('/c/:id',
    cacheHandler,
    fromClient({ page: 'Category' }),
    fromServer('./category/category-handler')
  )
  .get('/s/:id',
    cacheHandler,
    fromClient({ page: 'Subcategory' }),
    fromServer('./subcategory/subcategory-handler')
  )
  .get('/p/:id',
    cacheHandler,
    fromClient({ page: 'Product' }),
    fromServer('./product/product-handler')
  )
  // This API method is automatically called and state is updated when the product model's color
  // is changed. Refer to `product/images-handler.js` to see an example implementation of the handler.
  .get('/p/:id/images/:color',
    cacheHandler,
    fromServer('./product/images-handler')
  )
  .get('/cart',
    fromClient({ page: 'Cart' }),
    fromServer('./cart/cart-handler')
  )
  .get('/cart/add-from-amp.json',
    fromServer('./cart/add-from-amp-handler')
  )
  .get('/checkout',
    fromClient({ page: 'Checkout' }),
    fromServer('./checkout/checkout-handler')
  )
  .get('/search/suggest',
    fromServer('./search/suggest-handler'),
  )
  .get('/search',
    // Note: Search results and subcategory views are often the same.  In practice you may need to implement 
    // a different handler or view for search results.  For simplicity we just reuse the subcategory view and 
    // handler here.
    fromClient({ page: 'Subcategory' }),
    fromServer('./subcategory/subcategory-handler'),
  )
  // begin new /about route
  .get('/about', 
    fromClient({ page: 'About' }),
    fromServer('./about/about-handler')
  )
  // end new /about route
  .error((e, params, request, response) => {
    response.status(500)

    return {
      page: 'Error',
      error: e.message,
      loading: false,
      stack: e.stack
    }
  })
  .fallback(
    // when no route matches, pull in content from the upstream site
    proxyUpstream('./proxy/proxy-handler')
  )

Step 2: Define a Server-Side Handler

Our /about route's fromServer handler references a file that doesn't exist yet, about/about-handler. Let's create it. First, create an about direcory under /src/, then create a file called about-handler.js with the following contents:

import globalState from '../globalState'
import { withGlobalState } from 'react-storefront/router'
import fetch from 'fetch'

export default async function aboutHandler(params, request, response) {
  // Use jsonplaceholder.typicode.com to simulate a fetch to an upstream API
  const about = await fetch('https://jsonplaceholder.typicode.com/posts', {
    body: {
      siteName: "My Store",
      description: "My Store proudly offers the best products at the lowest prices!"
    },
    headers: {
      "content-type": "application/json"
    }
  }).then(res => res.json())

  // return the about data and the rest of the global state needed to render the app
  return withGlobalState(request, globalState, { about })
}

Let's dissect out server-side handler for /about:

Server side handlers export a single default function that returns a patch that will be applied to the state tree. This is typically where you would orchestrate requests to upstream APIs to formulate a result. Handler functions can also be declared with async or return a Promise.

After running our fromClient and fromServer handlers, the state tree will look like:

{
  page: "About",
  about: {
    siteName: "My Store",
    description: "My Store proudly offers the best products at the lowest prices!"
  }
}

When a user lands on /about, React Storefront will use this state tree to render the PWA on the server and return HTML. If the user navigates to /about from another page in the app, React Storefront will automatically make an AJAX request to /about.json, which will return this data as JSON. The data is then applied to the state tree on the client, causing the page to be displayed.

For a more in-depth look at React Storefront's router, see Routing.

Step 3: Create a Model

Our route handler returned some data that we need to render in the UI. In order for that data to be loaded into the PWA's state tree, we need to define a model to hold it and add the model to src/AppModel. Let's create a file called AboutModel.js in src/about:

import { types } from "mobx-state-tree"

export default types.model('AboutModel', {
  siteName: types.maybe(types.string),
  description: types.maybe(types.string)
})

Now let's add our new model to root model class, AppModel, defined in src/AppModel.js. Be sure to add the AboutModel import statement as well:

import { types } from "mobx-state-tree"
import AppModelBase from 'react-storefront/model/AppModelBase'
import CartModel from './cart/CartModel'
import CategoryModel from './category/CategoryModel'
import SubcategoryModel from './subcategory/SubcategoryModel'
import ProductModel from './product/ProductModel'
import AboutModel from './about/AboutModel' // the import to go with our AboutModel

const AppModel = types.compose(AppModelBase, 
  types.model("AppModel", {
    welcomeMessage: types.maybe(types.string),
    cart: types.optional(CartModel, {}),
    category: types.maybe(CategoryModel),
    subcategory: types.maybe(SubcategoryModel),
    product: types.maybe(ProductModel),
    about: types.maybe(AboutModel) // our new AboutModel goes here
  })
)

export default AppModel

We use types.maybe(AboutModel) so that about can hold a null value. This value will only be populated when the user visits /about.

Step 4: Add the View

Now that we have a route, data, and a place in the state tree to hold it, let's create the view to render it. Create a file called About.js in src/about:

import React, { Component } from 'react'
import { inject, observer } from 'mobx-react'
import Typography from '@material-ui/core/Typography'
import Container from 'react-storefront/Container'

@inject('app') // inject the state tree into the component's props
@observer // automatically re-render when the state-tree changes
export default class About extends Component {

  render() {
    const { app, classes } = this.props // app is an instance of AppModel, the root of the state tree.

    if (!app.about) return null

    return (
      <Container>{/* Container provides proper margins to make things look nice */}
        <Typography variant="h6">{app.about.siteName}</Typography> {/* Material UI's Typography component uses font the theme to keep fonts consistent across the app */}
        <Typography variant="body1">{app.about.description}</Typography>
      </Container>
    )
  }

}

Here we use MobX's @inject and @observer decorators to make the state tree available via this.props and automatically re-render when data in the state tree is changed. We also use a few simple components from material-ui and react-storefront to make the UI look nice.

Now all we need to do is add the view to the App. Remember the fromClient handler in our route definition?

.get('/about', 
  fromClient({ page: 'About', about: {} }), // <= here
  fromServer('./about/about-handler')
)

It sets a value of { page: 'About' } in the app's state tree. We use this value to instruct React Storefront to render our About view by adding it to the Pages element in src/App.js. Proceed to add the commented line containing About to your src/App.js:

<Pages
  components={universal => ({
    Home: universal(import('./home/Home')),
    Category: universal(import('./category/Category')),
    Subcategory: universal(import('./subcategory/Subcategory')),
    Product: universal(import('./product/Product')),
    Cart: universal(import('./cart/Cart')),
    Checkout: universal(import('./checkout/Checkout')),
    About: universal(import('./about/About')) // <= add the About view here
  })}     
/>

The keys in the Pages element correspond to the values of the page in AppModel. Here we use universal() from react-universal-component to ensure that the About view is lazy loaded, minimizing the PWA's bundle size.

Step 5: Add Styling

Lastly, lets jazz things up a bit by using the primary color from the theme as the color for the siteName. We do this using JSS and material-ui's @withStyles decorator. Add the following above @inject('app') in the About.js view:

import { withStyles } from '@material-ui/core'

@withStyles(theme => ({
  siteName: {
    color: theme.palette.primary.main
  }
}))

The @withStyles decorator injects a prop called classes, which contains the class we defined above. We can apply it to the Typography element like so:

  <Typography variant="h6" className={classes.siteName}>
    {app.about.siteName}
  </Typography>

To see the changes you just made take effect, your server should be started and it should automatically load the update in the browser. To view the about page specifically go to /about with the server running: https://mlocal.www.moovweb.com/about

Summing Up

By follow the steps above we created:

  • An isomorphic route for /about that works both as an initial landing page rendered on the server and client-side navigation.
  • A handler that returns some new data to be rendered
  • A place in the state tree to hold the new data
  • A view to render the new data