Full isomorphic create-react-app + SSR + npm packed shared components with CSS

Pavel Lokhmakov
7 min readApr 12, 2017

--

In this article we create ejected full isomorphic create-react-app based on universal-router, isomorphic-style-loader with possibility import components from npm packages with CSS injection.

Create application

create-react-app create-react-app-ssr
cd create-react-app-ssr
yarn run eject

Install presets and plugin

yarn add babel-preset-latest babel-preset-react babel-preset-stage-2 babel-plugin-transform-runtime

And create .babelrc

{
"presets": [
"latest",
"react",
"stage-2"
],
"plugins": [
[
"transform-runtime", {
"polyfill": false,
"regenerator": true
}
]
]
}

Install isomorphic-style-loader and universal-router

yarn add isomorphic-style-loader universal-router

Modify webpack configs

webpack.config.dev.js

...
{
test: /\.css$/,
loader: `isomorphic-style-loader!css?importLoaders=1&modules&localIdentName=[name]__[local]___[hash:base64:5]!postcss`
},
...
new webpack.DefinePlugin(env.stringified),
new webpack.DefinePlugin({
'process.env.BROWSER': true,
}),
new webpack.HotModuleReplacementPlugin(),
...

webpack.config.prod.js

...
{
test: /\.css$/,
loader: `isomorphic-style-loader!css?importLoaders=1&modules&localIdentName=[name]__[local]___[hash:base64:5]!postcss`
},
...
new webpack.DefinePlugin(env.stringified),
new webpack.DefinePlugin({
'process.env.BROWSER': true,
}),
new webpack.HotModuleReplacementPlugin(),
...

Remove all from ./src and create next folder structure

./src
./src/node_modules
./src/node_modules/components
./src/node_modules/core
./src/node_modules/pages
./src/node_modules/server

For demo app we need some extra libraries

yarn add semantic-ui-react prop-types query-string

Lets create App component in ./src/node_modules/components/App

App.js

import PropTypes            from 'prop-types'
import React from 'react'

const ContextType = {
insertCss: PropTypes.func.isRequired
}

class App extends React.PureComponent {
static propTypes = {
context: PropTypes.shape(ContextType).isRequired,
children: PropTypes.element.isRequired,
}

static childContextTypes = ContextType

getChildContext() {
return this.props.context
}

render() {
return React.Children.only(this.props.children)
}
}

export default App

App.css

@import url('//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.2/semantic.min.css');

body {
margin: 0;
padding: 0;
font-family: sans-serif;
}

package.json

{
"private": true,
"name": "App",
"main": "./App.js"
}

Create history management module in ./src/node_modules/core/history

history.js

import createBrowserHistory from 'history/createBrowserHistory'
export default process.env.BROWSER && createBrowserHistory()

go.js

import history              from './history'

export default (location) => {
history.push(location)
}

package.json

{
"private": true,
"name": "history",
"main": "./history.js"
}

Some pages in ./src/node_modules/pages

Root/Root.js

import React                from 'react'

import { Flex } from 'mind-flex'

import { Button } from 'semantic-ui-react'
import { Header } from 'semantic-ui-react'
import { Segment } from 'semantic-ui-react'


import go from 'core/history/go'

import s from './Root.css'

const Root = ({ title }) => (
<div className={ s.root }>
<Segment>
<Header>
{ title }
</Header>
<Flex center middle>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</Flex>
</Segment>
<Button onClick={ () => go(`/login`) }>Login</Button>
<Button onClick={ () => go(`/something`) }>NotFound</Button>
</div>
)

import withStyles from 'isomorphic-style-loader/lib/withStyles'

export default withStyles(s)(Root)

Root/Root.css

@import "components/App/App.css";

.root {
margin: 5px;
}

Root/route.js

import React                from 'react'


import Root from './Root'

const title = `Root`

export default {
path: '/',

action() {
return {
title,
component: <Root title={ title } />,
}
},
}

Login/Login.js

import React                from 'react'

import { Button } from 'semantic-ui-react'
import { Header } from 'semantic-ui-react'
import { Segment } from 'semantic-ui-react'


import go from 'core/history/go'

import s from './Login.css'

const Login = ({ title }) => (
<div className={ s.root }>
<Segment>
<Header>
{ title }
</Header>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</Segment>
<Button onClick={ () => go(`/`) }>Home</Button>
<Button onClick={ () => go(`/something`) }>NotFound</Button>
</div>
)

import withStyles from 'isomorphic-style-loader/lib/withStyles'

export default withStyles(s)(Login)

Login/Login.css

@import "components/App/App.css";

.root {
margin: 5px;
}

Login/route.js

import React                from 'react'


import Login from './Login'

const title = `Login`

export default {
path: '/login',

action() {
return {
title,
component: <Login title={ title } />,
}
},
}

NotFound/NotFound.js

import React                from 'react'

import { Button } from 'semantic-ui-react'
import { Header } from 'semantic-ui-react'
import { Segment } from 'semantic-ui-react'


import go from 'core/history/go'

import s from './NotFound.css'

const NotFound = () => (
<div className={ s.root }>
<Segment>
<Header>Page not found</Header>
</Segment>
<Button onClick={ () => go(`/`) }>Home</Button>
</div>
)

import withStyles from 'isomorphic-style-loader/lib/withStyles'

export default withStyles(s)(NotFound)

NotFound/NotFound.css

@import "components/App/App.css";

.root {
margin: 5px;
}

NotFound/route.js

import React                from 'react'


import NotFound from './NotFound'

const title = `NotFound`

export default {
path: '*',

action() {
return {
title,
component: <NotFound title={ title } />,
status: 404,
}
},
}

And now we ready to create client

./src/index.js

import React                from 'react'
import ReactDOM from 'react-dom'

import Router from 'universal-router'
import queryString from 'query-string'


import App from 'components/App'
import history from 'core/history'
import routes from 'pages/routes'

const context = {
insertCss: (...styles) => {
const removeCss = styles.map(x => x._insertCss())
return () => { removeCss.forEach(f => f()) }
},
}

const container = document.getElementById('root')
const router = new Router(routes)

let currentLocation = history.location

async function onLocationChange(location, action) {
currentLocation = location

try {
const route = await router.resolve({
path: location.pathname,
query: queryString.parse(location.search),
})

if (currentLocation.key !== location.key) return

if
(route.redirect) {
history.replace(route.redirect)
return
}

ReactDOM.render(
<App context={ context }>{ route.component }</App>,
container
)
} catch (error) {
console.error(error)
}
}

history.listen(onLocationChange)
onLocationChange(currentLocation)

Developer version is ready. U can run it by yarn start and using routing and CSS injection without SSR.

Start to create server configuration

In ./config/paths.js add

...

serverIndexJs: resolveApp('src/node_modules/server/server.js')
...

Copy webpack.config.prod.js to webpack.config.server.js and change next:

...entry: [
require.resolve('./polyfills'),
paths.serverIndexJs,
],
output: {
path: paths.appBuild,
filename: 'server.js',
libraryTarget: 'commonjs2',
},
target: 'node',
...
...

Create in ./src/node_modules/server/

server.js

import bodyParser           from 'body-parser'
import cookieParser from 'cookie-parser'
import express from 'express'
import { createServer } from 'http'

import fs from 'fs'

import Router from 'universal-router'

import App from 'components/App'
import Html from 'components/Html'
import routes from 'pages/routes'

import React from 'react'
import ReactDOM from 'react-dom/server'

const PORT = 3000

const app = express()
const server = createServer(app)

const router = new Router(routes)

const jsFiles = fs.readdirSync('./build/static/js')

app.use(express.static('./build'))
app.use(cookieParser())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())

app.get('*', async (req, res, next) => {
try {
const css = new Set()

const context = {
insertCss: (...styles) => {
styles.forEach(style => css.add(style._getCss()))
},
}

const route = await router.resolve({
path: req.path,
query: req.query,
})

if (route.redirect) {
res.redirect(route.status || 302, route.redirect)
return
}

const data = { ...route }
data.children = ReactDOM.renderToString(<App context={ context }>{ route.component }</App>)
data.styles = [
{ id: 'css', cssText: [...css].join('') },
]
data.scripts = [
`/static/js/${ jsFiles[0] }`
]

const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)
res.status(route.status || 200)
res.send(`<!doctype html>${html}`)
} catch (err) {
next(err)
}
})

app.use((err, req, res, next) => {
console.log(err)
res.status(err.status || 500)
res.send(`Internal server error`)
})

server.listen(PORT, () => {
console.log(`==> 🌎 http://0.0.0.0:${ PORT }/`)
})

Create Html component

./src/node_modules/components/Html

import PropTypes            from 'prop-types'
import React from 'react'

class Html extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
styles: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
cssText: PropTypes.string.isRequired,
}).isRequired),
scripts: PropTypes.arrayOf(PropTypes.string.isRequired),
children: PropTypes.string.isRequired,
}

static defaultProps = {
styles: [],
scripts: [],
}

render() {
const { title, description, styles, scripts, children } = this.props
return (
<html className="no-js" lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="x-ua-compatible" content="ie=edge" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
{styles.map(style =>
<style
key={ style.id }
id={ style.id }
dangerouslySetInnerHTML={{ __html: style.cssText }}
/>,
)}
</head>
<body>
<div
id="root"
dangerouslySetInnerHTML={{ __html: children }}
/>
{ scripts.map(script => <script key={script} src={script} />) }
</body>
</html>
)
}
}

export default Html

Add building scripts

Copy ./scripts/build.js to ./scripts/buildServer.js and change:

...
var config = require('../config/webpack.config.server');
...
measureFileSizesBeforeBuild(paths.appBuild).then(previousFileSizes => {
// Start the webpack build
build(previousFileSizes);
});
...

Clean function build as shown in our repository

./package.json

...
"start:ssr": "node build/server.js",
"build:server": "node scripts/buildServer.js",
...

All is done. You can run your server with SSR like this:

yarn build
yarn build:server
node start:ssr

To see:

And finally

U can see in create-react-app-ssr using of external module mind-flex that exported with CSS through isomorphic-style-loader

It’s shared component create using previous article Best way to create npm packages with create-react-app

--

--