One of the things I loved about Next.js when I first used it was that it made the massive boilerplate required by Webpack almost disappear. It also prescribed simple, logical conventions that if you followed, allowed you to be easily successful.
I found it to be a huge step up in simplicity compared to the previous complexity of creating Server Side Rendered (SSR) React applications.
However, early last year I became aware of a new tool that could solve the same issues for me while staying closer to the core React API.
One of my biggest gripes with Next.js is itās custom routingāāāalthough simple to useāāāthe alternative React Router is really great, and there are great animation libraries that go with it, and I like creating pretty lookinā easy to use things!
So in early 2018 I ditched Next.js and Webpack for something a bit ācloser to the metalā and started building React apps with Parcel.
In this article I want to show you how Iāve been building apps with Parcel to create streaming server side rendered react apps with styled-components.
If youāre wondering what Iām so excited or havenāt tried out Parcel yetāāāParcel is a newer module bundler in Javascript Land.
āGreat another tool I have to learnā You think.
Nah. Parcel doesnāt roll like that. Itās ZERO-CONFIG.
It just works.
You can importĀ .css files, images, and whatever else you want and it works exactly like youād expect it to.
This makes it really easy to make universal applications that use all of the latest and greatest in the React ecosystemāāācode-splitting, streaming rendering, and even differential bundlingāāāmaking it easy to get the latest in performance optimizations with very little effort!
I would like to use the new React lazy
and Suspense
APIs to implement the code-splitting, however, itās still not supported on the server side, so weāll be using a similar alternative.
In some cases it still may be slightly more verbose than Next.js, but for my use cases, I prefer the additional customizability. I think you will be surprised to see how simple things have gotten if itās been awhile since youāve evaluated your tooling.
This is intended for you to be able to follow along and end up with a nice new boilerplate.
I always have a personal goal for keeping things as lightweight as possible. If this werenāt SSR, Iād recommend checking out Hyperapp instead of React at all. I built a really cool JS SDK for a Shopify plugin that gave machine learning recommendations using it over the summer.
So what are we waiting for? Letās get started!
First, create a new project with the following directory structureāāāone file, two folders.
- app/- server/.gitignore
We will make a directory called stream-all-the-things
with mkdir
. Then we will cd into that directory and create a folder called app
and a folder called server
. Lastly, we will use touch
to create ourĀ .gitignore
file.
Hereās a quick little snippet to do it. Feel free to type each line or copy and paste the whole thing into your terminal.
mkdir stream-all-the-things && cd stream-all-the-thingsmkdir appmkdir servertouch .gitignore
Hereās the contents for ourĀ .gitignore
node_modules*.log.cachedist
Next, letās install the dependencies we will need.
npm init
npm i --save react react-dom react-router styled-components react-helmet-async react-imported-component
npm i --save-dev parcel-bundler react-hot-loader
Alright, a bit to unpack there. Though not much you havenāt seen before.
Thereās the base dependencies youāve probably used beforeā¦ react
, react-dom
, plus react-router
. Then we also have styled-components
to take advantage of its streaming rendering support. Beyond the fact that styled-components is a CSS-in-JS library that supports streaming rendering, I already preferred styled-components! Itās opinionated approach helps to enforce best practices as well as being friendly to CSS developers.
react-helmet-async
is an async version of the popular library react-helmet
that works with streaming SSR. It allows you to change information of in the head of the HTML document as you navigate. For instance, to update the title
of the page.
Also, we have parcel-bundler
which will do the bundling, cross-env
to nip some problems with Windows in the bud,nodemon
, for our developing our server, react-hot-loader
for developing our client, and rimraf
for cleaning up.
Seems how our goal is to develop, letās start with development mode.
Add a dev
script in your scripts
section of package.json
.
"scripts": {"dev": "parcel app/index.html"}
With Parcel, you simple give it the entrypoint to your application as the only argument to start developing.
Now letās create that app/index.html
file we referenced.
<!DOCTYPE html><html><head><meta charset="UTF-8"><meta content="text/html;charset=utf-8" http-equiv="Content-Type"><meta content="utf-8" http-equiv="encoding"><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"></head>
<body><div id="app"></div><script id="js-entrypoint" src="./client.js"></script></body></html>
In it, another reference to a file which we have not yet created: client.js
.
This is the entrypoint to our client application. In other words, the starting point. This is where out initial tree will be rendered.
Letās create app/client.js
and then I will break it down.
import React from 'react'import ReactDOM from 'react-dom'import App from './App'import { HelmetProvider } from 'react-helmet-async';
const element = document.getElementById('app')const app = (<HelmetProvider><App /></HelmetProvider>)
ReactDOM.render(app, element)
// Enable Hot Module Reloadingif (module.hot) {module.hot.accept();}
And lastly, before we can test anything out, we also need app/App.jsx
.
import React from 'react'import Helmet from 'react-helmet-async'
const App = () => (<React.Fragment>
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="[https://medium.com/@patrickleet](https://medium.com/@patrickleet)">[@patrickleet](http://twitter.com/patrickleet "Twitter profile for @patrickleet")</a>
</div>
</React.Fragment>)
export default App
Now, you should be able to run npm run dev
to start your development server with hot code reloading!
ā npm run dev
> [email protected] dev Users/me/dev/patrickleet/stream-all-the-things> parcel app/index.html
Server running at http://localhost:1234āØ Built in 192ms.
Letās check it out!
Because you are not me, try updating the page to a link of your own, and notice that you do not have to reload to see your changes!
I use a mix of global styles, and styled-components.
Letās add in some base resets and styles, as well as define a couple of useful CSS variables that will mathematically help us on our upcoming design adventures.
Create a file styles.js
:
import { createGlobalStyle } from 'styled-components'
export const GlobalStyles = createGlobalStyle`/* Base 10 typography scale courtesty of @wesbos 1.6rem === 16px */html {font-size: 10px;}
body {font-size: 1.6rem;}
/* Relative Type Scale *//* https://blog.envylabs.com/responsive-typographic-scales-in-css-b9f60431d1c4 */:root {--step-up-5: 2em;--step-up-4: 1.7511em;--step-up-3: 1.5157em;--step-up-2: 1.3195em;--step-up-1: 1.1487em;/* baseline: 1em */--step-down-1: 0.8706em;--step-down-2: 0.7579em;--step-down-3: 0.6599em;--step-down-4: 0.5745em;--step-down-5: 0.5em;/* Colors */--header: rgb(0,0,0);}
/* https://css-tricks.com/snippets/css/system-font-stack/ *//* Define the "system" font family *//* Fastest loading font - the one native to their device */@font-face {font-family: system;font-style: normal;font-weight: 300;src: local(".SFNSText-Light"), local(".HelveticaNeueDeskInterface-Light"), local(".LucidaGrandeUI"), local("Ubuntu Light"), local("Segoe UI Light"), local("Roboto-Light"), local("DroidSans"), local("Tahoma");}
/* Modern CSS Reset *//* https://alligator.io/css/minimal-css-reset/ */body, h1, h2, h3, h4, h5, h6, p, ol, ul, input[type=text], input[type=email], button {margin: 0;padding: 0;font-weight: normal;}
body, h1, h2, h3, h4, h5, h6, p, ol, ul, input[type=text], input[type=email], button {font-family: "system"}
*, *:before, *:after {box-sizing: inherit;}
ol, ul {list-style: none;}
img {max-width: 100%;height: auto;}
/* Links */a {text-decoration: underline;color: inherit;
&.active {text-decoration: none;}}
`
In app/App.jsx
import GlobalStyles
:
import { GlobalStyles } from './styles'
And then change App
to render the GlobalStyles
component.
const App = () => (<div><GlobalStyles />Follow me at <a href="https://medium.com/@patrickleet">@patrickleet</a></div>)
Your app should look slightly less ugly.
The next thing we need is for pages to be easy.
Letās add in React Router.
In your client we need to import the BrowserRouter
from React Router, and then simply wrap our app with it.
In app/client.js
import { BrowserRouter } from 'react-router-dom'
// ...
const app = (<HelmetProvider><BrowserRouter><GlobalStyles /><App /></BrowserRouter></HelmetProvider>)
Now in app/App.jsx
we need to extract our current content into a new component and load in through the router instead. Letās start with creating a new page, using pretty much the same content as we have in App.jsx
currently.
Create app/pages/Home.jsx
:
import React from 'react'import Helmet from 'react-helmet-async'
const Home = () => (<React.Fragment>
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="[https://medium.com/@patrickleet](https://medium.com/@patrickleet)">[@patrickleet](http://twitter.com/patrickleet "Twitter profile for @patrickleet")</a>
</div>
</React.Fragment>)
export default Home
Then, modify App.jsx
to have the following content:
import React from 'react'import { Switch, Route, Redirect } from 'react-router-dom'import Home from './pages/Home'
const App = () => (<React.Fragment><GlobalStyles /><Switch><Route exact path="/" component={Home} /><Redirect to="/" /></Switch></React.Fragment>)
export default App
Now when we run our app, it should look the same as before, except this time it is rendering through our router based on the match of the route /
.
Before we move on, letās add a second route, but this time with ācode splittingā.
Letās create a second page, app/pages/About.jsx
:
import React from 'react'import Helmet from 'react-helmet-async'
const About = () => (<React.Fragment>
<Helmet>
<title>About Page</title>
</Helmet>
<div>
This is the about page
</div>
</React.Fragment>)
export default About
And a loading component at app/pages/Loading.jsx
:
import React from 'react'
const Loading = () => (<div>Loading...</div>)export default Loading
And finally an Error Component at app/pages/Error.jsx
:
import React from 'react'
const Error = () => (<div>Error!</div>)export default Error
To import it, Iād like to make use of the new React.lazy
and Suspense
APIs, unfortunately, while they will work on the client, once we get to Server Side Rendering we will find that ReactDomServer does not yet support Suspense.
Instead, we will rely on another library called react-imported-component
which will work with client side and server side rendered apps.
Hereās our updated app/App.jsx
:
import React from 'react'import { Switch, Route, Redirect } from 'react-router-dom';import importComponent from 'react-imported-component';import Home from './pages/Home.jsx'import LoadingComponent from './pages/Loading'import ErrorComponent from './pages/Error'
const About = importComponent(() => import("./pages/About"), {LoadingComponent,ErrorComponent});
const App = () => (<React.Fragment><GlobalStyles />
<Switch>
<Route exact path="/" component={Home} />
**<Route exact path="/about" render={() => <About />} />**
<Redirect to="/" />
</Switch>
</React.Fragment>)
export default App
Now we should be able to navigate to /about
to see our new page. If you look quickly, you will see Loading...
appear before the page content.
Right now we need to navigate via typing routes into the address bar, which is less than ideal. Before we move onto Server Side Rendering, letās add a common layout to our pages and a Header with navigation to get around.
Letās start with a Header so we can get clickinā.
Create app/components/Header.jsx
:
import React from 'react';import styled from 'styled-components'import { NavLink } from 'react-router-dom';
const Header = styled.header`z-index: 100;position: fixed;top: 0;left: 0;right: 0;
max-width: 90vw;margin: 0 auto;padding: 1em 0;
display: flex;justify-content: space-between;align-items: center;`
const Brand = styled.h1`font-size: var(--step-up-1);`
const Menu = styled.ul`display: flex;justify-content: flex-end;align-items: center;width: 50vw;`
const MenuLink = styled.li`margin-left: 2em;text-decoration: none;`
export default () => (<Header><Brand>Stream all the things!</Brand><Menu><MenuLink><NavLinkto="/"exact activeClassName="active">Home</NavLink></MenuLink><MenuLink><NavLinkto="/about"exact activeClassName="active">About</NavLink></MenuLink></Menu></Header>)
And we need to import it and place it into our App
.
Hereās the updated App.jsx
:
import React from 'react'import { Switch, Route, Redirect } from 'react-router-dom';import importComponent from 'react-imported-component';import { GlobalStyles } from './styles'**import Header from './components/Header'**import Home from './pages/Home'import LoadingComponent from './pages/Loading'import ErrorComponent from './pages/Error'
const About = importComponent(() => import("./pages/About"), {LoadingComponent,ErrorComponent});
const App = () => (<React.Fragment><GlobalStyles /><Header />
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/about" render={() => <About />} />
<Redirect to="/" />
</Switch>
</React.Fragment>)
export default App
And letās also create a Page
component that each of our pages can use for a consistent Page style.
Create app/components/Page.jsx
:
import styled from 'styled-components';
const Page = styled.div`width: 100vw;height: 100vh;display: flex;justify-content: center;align-items: center;text-align: center;`
export default Page
Then, in our four pages, import the new Page
component, and replace the wrapping React.Fragment
in each page with it.
Here is the Home
page:
import React from 'react'import Helmet from 'react-helmet-async'import Page from '../components/Page.jsx'
const Home = () => (<Page>
<Helmet>
<title>Home Page</title>
</Helmet>
<div>
Follow me at <a href="[https://medium.com/@patrickleet](https://medium.com/@patrickleet)">[@patrickleet](http://twitter.com/patrickleet "Twitter profile for @patrickleet")</a>
</div>
</Page>)
export default Home
And do the same for the About
page, as well as the Error
and Loading
pages.
Our app is starting to look a bit nicer!
There are obviously infinite possible ways to style this app, so Iāll leave making things prettier as an exercise.
The next step for us to reach the our goal is adding in the streaming server side rendering. If youāve been paying attention, youāve noticed that so far weāve created a static client side application.
Going from client side to isomorphic requires creating a new entrypoint on the server, which will then load the same App
component that our client entrypoint loads.
We will also need several other new npm packages:
npm i --save llog pino express through cheerio
npm i --save-dev concurrently rimraf nodemon @babel/polyfill cross-env
Letās create server/index.js
:
import path from 'path'import express from 'express'import log from 'llog'import ssr from './lib/ssr'
const app = express()
// Expose the public directory as /dist and point to the browser versionapp.use('/dist/client', express.static(path.resolve(process.cwd(), 'dist', 'client')));
// Anything unresolved is serving the application and let// react-router do the routing!app.get('/*', ssr)
// Check for PORT environment variable, otherwise fallback on Parcel default portconst port = process.env.PORT || 1234;app.listen(port, () => {log.info(`Listening on port ${port}...`);});
Ok, a couple things to unpack here:
/dist/clients
directory. We arenāt currently building production assets, but when we do, we can put them there.ssr
. Instead of bothering with routing on the server, we just do whatever React Router does.Letās create the ssr
function. This will probably be more complicated than the rest of the tutorial, but itās only something that needs to be done once, and then largely left alone.
Before we continue, letās take a look at the scripts we need to create.
"scripts": {"dev": "npm run generate-imported-components && parcel app/index.html","dev:server": "nodemon -e js,jsx,html --ignore dist --ignore app/imported.js --exec 'npm run build && npm run start'","start": "node dist/server""build": "rimraf dist && npm run generate-imported-components && npm run create-bundles","create-bundles": "concurrently \"npm run create-bundle:client\" \"npm run create-bundle:server\"","create-bundle:client": "cross-env BABEL_ENV=client parcel build app/index.html -d dist/client --public-url /dist/client","create-bundle:server": "cross-env BABEL_ENV=server parcel build server/index.js -d dist/server --public-url /dist --target=node","generate-imported-components": "imported-components app app/imported.js","start": "node dist/server"}
There are quite a few more now. Iāve highlighted the names to make it easier to read. At a high level, we added build scripts to generate a file containing info about imported components, as well as a build script which concurrently builds the client and server bundles using parcel.
We will also need aĀ .babelrc
file for the imported components for now. Maybe in the next few months this will change.
{"env": {"server": {"plugins": ["react-imported-component/babel", "babel-plugin-dynamic-import-node"]},"client": {"plugins": [["react-imported-component/babel"]]}}}
With that out of the way, we have two major pieces to solve.
Create server/lib/ssr.js
:
import React from 'react'import { renderToNodeStream } from 'react-dom/server'import { HelmetProvider } from 'react-helmet-async'import { StaticRouter } from 'react-router-dom'import { ServerStyleSheet } from 'styled-components'import { printDrainHydrateMarks } from 'react-imported-component';import log from 'llog'import through from 'through'import App from '../../app/App'import { getHTMLFragments } from './client'// import { getDataFromTree } from 'react-apollo';
export default (req, res) => {const context = {};const helmetContext = {};
const app = (<HelmetProvider context={helmetContext}><StaticRouterlocation={req.originalUrl}context={context}><App /></StaticRouter></HelmetProvider>);
try {// If you were using Apollo, you could fetch data with this// await getDataFromTree(app);
const sheet = new ServerStyleSheet()
const stream = sheet.interleaveWithNodeStream(
renderToNodeStream(sheet.collectStyles(app))
)
if (context.url) {
res.redirect(301, context.url);
} else {
const \[
startingHTMLFragment,
endingHTMLFragment
\] = getHTMLFragments({ drainHydrateMarks: printDrainHydrateMarks() })
res.status(200)
res.write(startingHTMLFragment)
stream
.pipe(
through(
function write(data) {
this.queue(data)
},
function end() {
this.queue(endingHTMLFragment)
this.queue(null)
}
)
)
.pipe(res)
}
} catch (e) {log.error(e)res.status(500)res.end()}};
And withserver/lib/client.js
we need to read in our app/index.html
file and break it into the two chunks that make streaming easier up above.
import fs from 'fs';import path from 'path';import cheerio from 'cheerio';
export const htmlPath = path.join(process.cwd(), 'dist', 'client', 'index.html');export const rawHTML = fs.readFileSync(htmlPath).toString();
export const parseRawHTMLForData = (template, selector = "#js-entrypoint") => {const $template = cheerio.load(template);let src = $template(selector).attr('src')
return {src}}
const clientData = parseRawHTMLForData(rawHTML)
const appString = '<div id="app"\>'const splitter = '###SPLIT###'const [startingRawHTMLFragment,endingRawHTMLFragment] = rawHTML.replace(appString, `${appString}${splitter}`).split(splitter)
export const getHTMLFragments = ({ drainHydrateMarks }) => {const startingHTMLFragment = `${startingRawHTMLFragment}${drainHydrateMarks}`return [startingHTMLFragment, endingRawHTMLFragment]}
This will render our app via the server, however it wonāt succeed in reconnecting to the client app without a few small changes to the client.
We are providing ārehydrate marksā via our SSR function, but not making use of them yet.
Over in app/client.js
make the following modifications:
rehydrateMarks
and importedComponents
import { rehydrateMarks } from 'react-imported-component';import importedComponents from './imported'; // eslint-disable-line
2. Replace ReactDOM.render(app, element)
with:
// In production, we want to hydrate instead of render// because of the server-renderingif (process.env.NODE_ENV === 'production') {// rehydrate the bundle marksrehydrateMarks().then(() => {ReactDOM.hydrate(app, element);});} else {ReactDOM.render(app, element);}
And done!
Now, when you run npm run dev:server
or npm run build && npm run start
you will be using server side rendering!
Iāll admit, there is still more boilerplate than Next.js, but hopefully itās not overwhelmingly so, and what is there is transparent and understandable. And to be fair, Next.js is still doing a few more things for us, like prefetching components.
However, I still prefer this approach because there is no mystery in what is going on, webpack configs are completely gone, and itās easy to make use of animation libraries for react router which Iāll leave as an exercise.
Hopefully youāve found this useful!
If you did, the best way to help me is by giving me some claps and/or a share!
Best,Patrick Lee Scott
P.S. Hereās the full code on GitHub.
P. P. S. This article is part of a series. Check out the other parts below!
Part 2: A Better Way to Develop Node.js with Docker_And Keep Your Hot Code Reloading_hackernoon.com
Part 3: Enforcing Code Quality for Node.js_Using Linting, Formatting, and Unit Testing with Code Coverage to Enforce Quality Standards_hackernoon.com
Part 4: The 100% Code Coverage Myth_Thereās a lot of advice around the internet right now saying that 100% coverage is not a worthwhile goal. Is it?_hackernoon.com
Part 5: A Tale of Two (Docker Multi-Stage Build) Layers_Production Ready Dockerfiles for Node.js_hackernoon.com