Move over Next.js and Webpack 🤯

Simple Streaming SSR React with Styled-Components and Parcel

Photo via my Adobe Stock Photo license.

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!

1. Setup

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-things
mkdir app
mkdir server
touch .gitignore

Here’s the contents for our .gitignore

node_modules
*.log
.cache
dist

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.

2. Development mode with parcel

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 Reloading
if (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">@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
> stream-all-the-things@1.0.0 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!

3. Add some style

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.

4. Routing

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">@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.

5. Layout and Navigation

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>
<NavLink
to="/"
exact activeClassName="active"
>Home</NavLink>
</MenuLink>
<MenuLink>
<NavLink
to="/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">@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.

6. Streaming Server Side Rendering

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 version
app.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 port
const port = process.env.PORT || 1234;
app.listen(port, () => {
log.info(`Listening on port ${port}...`);
});

Ok, a couple things to unpack here:

  1. We are using express — it could easily be any other server. We’re really not doing much so it shouldn’t be too hard to convert to the server of your choice.
  2. We are setting up a static file server for the /dist/clients directory. We aren’t currently building production assets, but when we do, we can put them there.
  3. Every other route is going the 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.

  1. Creating the SSR middleware
  2. Reusing the client HTML data for SSR and parsing the generated src name out of it

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}>
<StaticRouter
location={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:

  1. Import 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-rendering
if (process.env.NODE_ENV === 'production') {
// rehydrate the bundle marks
rehydrateMarks().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!

Conclusion

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!

More by Patrick Lee Scott

Topics of interest

More Related Stories