paint-brush
Move over Next.js and Webpack šŸ¤Æā€‚by@patrickleet
49,269 reads
49,269 reads

Move over Next.js and Webpack šŸ¤Æ

by Patrick Lee ScottApril 13th, 2018
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Parcel is a newer module bundler in Javascript Land. Itā€™s ZERO-CONFIG.css files, images, and whatever else you want and it works exactly like youā€™d expect it to. It 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 optimization with very little effort! Weā€™ll be using a similar alternative to Next.js.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Move over Next.js and Webpack šŸ¤Æ
Patrick Lee Scott HackerNoon profile picture

Simple Streaming SSR React with Styled-Components andĀ Parcel

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 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-thingsmkdir appmkdir servertouch .gitignore

Hereā€™s the contents for ourĀ .gitignore




node_modules*.log.cachedistNext, 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 . 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 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="[//medium.com/@patrickleet](//medium.com/@patrickleet)">[@patrickleet](//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 āœØ 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 1.6rem === 16px */html {font-size: 10px;}



body {font-size: 1.6rem;}

















/* Relative Type Scale *//* */: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);}









/* *//* Define the "system" font family *//* Fastest loading font - the one native to their device */-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 *//* */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=""></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="[//medium.com/@patrickleet](//medium.com/@patrickleet)">[@patrickleet](//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.

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><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="[//medium.com/@patrickleet](//medium.com/@patrickleet)">[@patrickleet](//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.

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 cheerionpm 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:
  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}><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:

  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-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!

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,P.S. Hereā€™s the . 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_gzht888.com


Part 3: Enforcing Code Quality for Node.js_Using Linting, Formatting, and Unit Testing with Code Coverage to Enforce Quality Standards_gzht888.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?_gzht888.com


Part 5: A Tale of Two (Docker Multi-Stage Build) Layers_Production Ready Dockerfiles for Node.js_gzht888.com

ė°”ģ¹“ė¼ģ‚¬ģ“ķŠø ė°”ģ¹“ė¼ģ‚¬ģ“ķŠø ģ˜Øė¼ģøė°”ģ¹“ė¼