Skip to content

Latest commit

History

History
590 lines (429 loc) 路 17 KB

06-react-router-ssr-helmet.md

File metadata and controls

590 lines (429 loc) 路 17 KB

06 - React Router, Server-Side Rendering, and Helmet

Code for this chapter available here.

In this chapter we are going to create different pages for our app and make it possible to navigate between them.

React Router

馃挕 React Router is a library to navigate between pages in your React app. It can be used on both the client and the server.

  • Run yarn add react-router react-router-dom

On the client side, we first need to wrap our app inside a BrowserRouter component.

  • Update your src/client/index.jsx like so:
// [...]
import { BrowserRouter } from 'react-router-dom'
// [...]
const wrapApp = (AppComponent, reduxStore) =>
  <Provider store={reduxStore}>
    <BrowserRouter>
      <AppContainer>
        <AppComponent />
      </AppContainer>
    </BrowserRouter>
  </Provider>

Pages

Our app will have 4 pages:

  • A Home page.

  • A Hello page showing a button and message for the synchronous action.

  • A Hello Async page showing a button and message for the asynchronous action.

  • A 404 "Not Found" page.

  • Create a src/client/component/page/home.jsx file containing:

// @flow

import React from 'react'

const HomePage = () => <p>Home</p>

export default HomePage
  • Create a src/client/component/page/hello.jsx file containing:
// @flow

import React from 'react'

import HelloButton from '../../container/hello-button'
import Message from '../../container/message'

const HelloPage = () =>
  <div>
    <Message />
    <HelloButton />
  </div>

export default HelloPage
  • Create a src/client/component/page/hello-async.jsx file containing:
// @flow

import React from 'react'

import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'

const HelloAsyncPage = () =>
  <div>
    <MessageAsync />
    <HelloAsyncButton />
  </div>

export default HelloAsyncPage
  • Create a src/client/component/page/not-found.jsx file containing:
// @flow

import React from 'react'

const NotFoundPage = () => <p>Page not found</p>

export default NotFoundPage

Navigation

Let's add some routes in the shared config file.

  • Edit your src/shared/routes.js like so:
// @flow

export const HOME_PAGE_ROUTE = '/'
export const HELLO_PAGE_ROUTE = '/hello'
export const HELLO_ASYNC_PAGE_ROUTE = '/hello-async'
export const NOT_FOUND_DEMO_PAGE_ROUTE = '/404'

export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`

The /404 route is just going to be used in a navigation link for the sake of demonstrating what happens when you click on a broken link.

  • Create a src/client/component/nav.jsx file containing:
// @flow

import React from 'react'
import { NavLink } from 'react-router-dom'
import {
  HOME_PAGE_ROUTE,
  HELLO_PAGE_ROUTE,
  HELLO_ASYNC_PAGE_ROUTE,
  NOT_FOUND_DEMO_PAGE_ROUTE,
} from '../../shared/routes'

const Nav = () =>
  <nav>
    <ul>
      {[
        { route: HOME_PAGE_ROUTE, label: 'Home' },
        { route: HELLO_PAGE_ROUTE, label: 'Say Hello' },
        { route: HELLO_ASYNC_PAGE_ROUTE, label: 'Say Hello Asynchronously' },
        { route: NOT_FOUND_DEMO_PAGE_ROUTE, label: '404 Demo' },
      ].map(link => (
        <li key={link.route}>
          <NavLink to={link.route} activeStyle={{ color: 'limegreen' }} exact>{link.label}</NavLink>
        </li>
      ))}
    </ul>
  </nav>

export default Nav

Here we simply create a bunch of NavLinks that use the previously declared routes.

  • Finally, edit src/client/app.jsx like so:
// @flow

import React from 'react'
import { Switch } from 'react-router'
import { Route } from 'react-router-dom'
import { APP_NAME } from '../shared/config'
import Nav from './component/nav'
import HomePage from './component/page/home'
import HelloPage from './component/page/hello'
import HelloAsyncPage from './component/page/hello-async'
import NotFoundPage from './component/page/not-found'
import {
  HOME_PAGE_ROUTE,
  HELLO_PAGE_ROUTE,
  HELLO_ASYNC_PAGE_ROUTE,
} from '../shared/routes'

const App = () =>
  <div>
    <h1>{APP_NAME}</h1>
    <Nav />
    <Switch>
      <Route exact path={HOME_PAGE_ROUTE} render={() => <HomePage />} />
      <Route path={HELLO_PAGE_ROUTE} render={() => <HelloPage />} />
      <Route path={HELLO_ASYNC_PAGE_ROUTE} render={() => <HelloAsyncPage />} />
      <Route component={NotFoundPage} />
    </Switch>
  </div>

export default App

馃弫 Run yarn start and yarn dev:wds. Open http://localhost:8000, and click on the links to navigate between our different pages. You should see the URL changing dynamically. Switch between different pages and use the back button of your browser to see that the browsing history is working as expected.

Now, let's say you navigated to http://localhost:8000/hello this way. Hit the refresh button. You now get a 404, because our Express server only responds to /. As you navigated between pages, you were actually only doing it on the client-side. Let's add server-side rendering to the mix to get the expected behavior.

Server-Side Rendering

馃挕 Server-Side Rendering means rendering your app at the initial load of the page instead of relying on JavaScript to render it in the client's browser.

SSR is essential for SEO and provides a better user experience by showing the app to your users right away.

The first thing we're going to do here is to migrate most of our client code to the shared / isomorphic / universal part of our codebase, since the server is now going to render our React app too.

The big migration to shared

  • Move all the files located under client to shared, except src/client/index.jsx.

We have to adjust a whole bunch of imports:

  • In src/client/index.jsx, replace the 3 occurrences of './app' by '../shared/app', and './reducer/hello' by '../shared/reducer/hello'

  • In src/shared/app.jsx, replace '../shared/routes' by './routes' and '../shared/config' by './config'

  • In src/shared/component/nav.jsx, replace '../../shared/routes' by '../routes'

Server changes

  • Create a src/server/routing.js file containing:
// @flow

import {
  homePage,
  helloPage,
  helloAsyncPage,
  helloEndpoint,
} from './controller'

import {
  HOME_PAGE_ROUTE,
  HELLO_PAGE_ROUTE,
  HELLO_ASYNC_PAGE_ROUTE,
  helloEndpointRoute,
} from '../shared/routes'

import renderApp from './render-app'

export default (app: Object) => {
  app.get(HOME_PAGE_ROUTE, (req, res) => {
    res.send(renderApp(req.url, homePage()))
  })

  app.get(HELLO_PAGE_ROUTE, (req, res) => {
    res.send(renderApp(req.url, helloPage()))
  })

  app.get(HELLO_ASYNC_PAGE_ROUTE, (req, res) => {
    res.send(renderApp(req.url, helloAsyncPage()))
  })

  app.get(helloEndpointRoute(), (req, res) => {
    res.json(helloEndpoint(req.params.num))
  })

  app.get('/500', () => {
    throw Error('Fake Internal Server Error')
  })

  app.get('*', (req, res) => {
    res.status(404).send(renderApp(req.url))
  })

  // eslint-disable-next-line no-unused-vars
  app.use((err, req, res, next) => {
    // eslint-disable-next-line no-console
    console.error(err.stack)
    res.status(500).send('Something went wrong!')
  })
}

This file is where we deal with requests and responses. The calls to business logic are externalized to a different controller module.

Note: You will find a lot of React Router examples using * as the route on the server, leaving the entire routing handling to React Router. Since all requests go through the same function, that makes it inconvenient to implement MVC-style pages. Instead of doing that, we're here explicitly declaring the routes and their dedicated responses, to be able to fetch data from the database and pass it to a given page easily.

  • Create a src/server/controller.js file containing:
// @flow

export const homePage = () => null

export const helloPage = () => ({
  hello: { message: 'Server-side preloaded message' },
})

export const helloAsyncPage = () => ({
  hello: { messageAsync: 'Server-side preloaded message for async page' },
})

export const helloEndpoint = (num: number) => ({
  serverMessage: `Hello from the server! (received ${num})`,
})

Here is our controller. It would typically make business logic and database calls, but in our case we just hard-code some results. Those results are passed back to the routing module to be used to initialize our server-side Redux store.

  • Create a src/server/init-store.js file containing:
// @flow

import Immutable from 'immutable'
import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'

import helloReducer from '../shared/reducer/hello'

const initStore = (plainPartialState: ?Object) => {
  const preloadedState = plainPartialState ? {} : undefined

  if (plainPartialState && plainPartialState.hello) {
    // flow-disable-next-line
    preloadedState.hello = helloReducer(undefined, {})
      .merge(Immutable.fromJS(plainPartialState.hello))
  }

  return createStore(combineReducers({ hello: helloReducer }),
    preloadedState, applyMiddleware(thunkMiddleware))
}

export default initStore

The only thing we do here, besides calling createStore and applying middleware, is to merge the plain JS object we received from the controller into a default Redux state containing Immutable objects.

  • Edit src/server/index.js like so:
// @flow

import compression from 'compression'
import express from 'express'

import routing from './routing'
import { WEB_PORT, STATIC_PATH } from '../shared/config'
import { isProd } from '../shared/util'

const app = express()

app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))

routing(app)

app.listen(WEB_PORT, () => {
  // eslint-disable-next-line no-console
  console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
    '(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
})

Nothing special here, we just call routing(app) instead of implementing routing in this file.

  • Rename src/server/render-app.js to src/server/render-app.jsx and edit it like so:
// @flow

import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router'

import initStore from './init-store'
import App from './../shared/app'
import { APP_CONTAINER_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
import { isProd } from '../shared/util'

const renderApp = (location: string, plainPartialState: ?Object, routerContext: ?Object = {}) => {
  const store = initStore(plainPartialState)
  const appHtml = ReactDOMServer.renderToString(
    <Provider store={store}>
      <StaticRouter location={location} context={routerContext}>
        <App />
      </StaticRouter>
    </Provider>)

  return (
    `<!doctype html>
    <html>
      <head>
        <title>FIX ME</title>
        <link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
      </head>
      <body>
        <div class="${APP_CONTAINER_CLASS}">${appHtml}</div>
        <script>
          window.__PRELOADED_STATE__ = ${JSON.stringify(store.getState())}
        </script>
        <script src="${isProd ? STATIC_PATH : `http://localhost:${WDS_PORT}/dist`}/js/bundle.js"></script>
      </body>
    </html>`
  )
}

export default renderApp

ReactDOMServer.renderToString is where the magic happens. React will evaluate our entire shared App, and return a plain string of HTML elements. Provider works the same as on the client, but on the server, we wrap our app inside StaticRouter instead of BrowserRouter. In order to pass the Redux store from the server to the client, we pass it to window.__PRELOADED_STATE__ which is just some arbitrary variable name.

Note: Immutable objects implement the toJSON() method which means you can use JSON.stringify to turn them into plain JSON strings.

  • Edit src/client/index.jsx to use that preloaded state:
import Immutable from 'immutable'
// [...]

/* eslint-disable no-underscore-dangle */
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const preloadedState = window.__PRELOADED_STATE__
/* eslint-enable no-underscore-dangle */

const store = createStore(combineReducers(
  { hello: helloReducer }),
  { hello: Immutable.fromJS(preloadedState.hello) },
  composeEnhancers(applyMiddleware(thunkMiddleware)))

Here with feed our client-side store with the preloadedState that was received from the server.

馃弫 You can now run yarn start and yarn dev:wds and navigate between pages. Refreshing the page on /hello, /hello-async, and /404 (or any other URI), should now work correctly. Notice how the message and messageAsync vary depending on if you navigated to that page from the client or if it comes from server-side rendering.

React Helmet

馃挕 React Helmet: A library to inject content to the head of a React app, on both the client and the server.

I purposely made you write FIX ME in the title to highlight the fact that even though we are doing server-side rendering, we currently do not fill the title tag properly (or any of the tags in head that vary depending on the page you're on).

  • Run yarn add react-helmet

  • Edit src/server/render-app.jsx like so:

import Helmet from 'react-helmet'
// [...]
const renderApp = (/* [...] */) => {
  // [...]
  const appHtml = ReactDOMServer.renderToString(/* [...] */)
  const head = Helmet.rewind()

  return (
    `<!doctype html>
    <html>
      <head>
        ${head.title}
        ${head.meta}
        <link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
      </head>
    [...]
    `
  )
}

React Helmet uses react-side-effect's rewind to pull out some data from the rendering of our app, which will soon contain some <Helmet /> components. Those <Helmet /> components are where we set the title and other head details for each page. Note that Helmet.rewind() must come after ReactDOMServer.renderToString().

  • Edit src/shared/app.jsx like so:
import Helmet from 'react-helmet'
// [...]
const App = () =>
  <div>
    <Helmet titleTemplate={`%s | ${APP_NAME}`} defaultTitle={APP_NAME} />
    <Nav />
    // [...]
  • Edit src/shared/component/page/home.jsx like so:
// @flow

import React from 'react'
import Helmet from 'react-helmet'

import { APP_NAME } from '../../config'

const HomePage = () =>
  <div>
    <Helmet
      meta={[
        { name: 'description', content: 'Hello App is an app to say hello' },
        { property: 'og:title', content: APP_NAME },
      ]}
    />
    <h1>{APP_NAME}</h1>
  </div>

export default HomePage
  • Edit src/shared/component/page/hello.jsx like so:
// @flow

import React from 'react'
import Helmet from 'react-helmet'

import HelloButton from '../../container/hello-button'
import Message from '../../container/message'

const title = 'Hello Page'

const HelloPage = () =>
  <div>
    <Helmet
      title={title}
      meta={[
        { name: 'description', content: 'A page to say hello' },
        { property: 'og:title', content: title },
      ]}
    />
    <h1>{title}</h1>
    <Message />
    <HelloButton />
  </div>

export default HelloPage
  • Edit src/shared/component/page/hello-async.jsx like so:
// @flow

import React from 'react'
import Helmet from 'react-helmet'

import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'

const title = 'Async Hello Page'

const HelloAsyncPage = () =>
  <div>
    <Helmet
      title={title}
      meta={[
        { name: 'description', content: 'A page to say hello asynchronously' },
        { property: 'og:title', content: title },
      ]}
    />
    <h1>{title}</h1>
    <MessageAsync />
    <HelloAsyncButton />
  </div>

export default HelloAsyncPage
  • Edit src/shared/component/page/not-found.jsx like so:
// @flow

import React from 'react'
import Helmet from 'react-helmet'

const title = 'Page Not Found'

const NotFoundPage = () =>
  <div>
    <Helmet
      title={title}
      meta={[
        { name: 'description', content: 'A page to say hello' },
        { property: 'og:title', content: title },
      ]}
    />
    <h1>{title}</h1>
  </div>

export default NotFoundPage

The <Helmet> component doesn't actually render anything, it just injects content in the head of your document and exposes the same data to the server.

馃弫 Run yarn start and yarn dev:wds and navigate between pages. The title on your tab should change when you navigate, and it should also stay the same when you refresh the page. Show the source of the page to see how React Helmet sets the title and meta tags even for server-side rendering.

Next section: 07 - Socket.IO

Back to the previous section or the table of contents.