DEV Community

Cover image for Adding style to Server-Side rendering and automating the build process
Anshuman Chhapolia
Anshuman Chhapolia

Posted on

Adding style to Server-Side rendering and automating the build process

In the previous post in series, I demonstrated how we can easily set up a project for Server-Side Rendering from scratch. I appreciate @silverspear28 and @coderbhaai for building their application using the previously proposed structure. Their feedback was very helpful in improving this project.

Prerequisites

  • Node.js
  • npm
  • Your preferred editor

Note: You must have basic knowledge of React and Express before moving forward. Best place to learn React is React Documentation. Express is the de facto standard framework for Node.js and you can find many resources online to learn it.

Things I'll cover in this post

  1. Creating a project for SSR from scratch (including writing babel and webpack configs).
  2. Adding styles and images to our page

I'll be explaining webpack and babel in detail so people who are not familiar with these can also understand the config files.

Setting up the project directory.

I've already covered this in the previous post. Please refer it for the details and explanation of the steps performed.


|-- project
     |-- .babelrc.js
     |-- package-lock.json
     |-- package.json
     |-- webpack.config.js
     |-- src
          |-- server.js
          |-- components
          |   |-- entrypoints
          |   |     |-- index.jsx
          |-- pages
          |   |-- index.jsx
          |   |-- index.css
          |   |-- img.jpg
          |-- routes
          |   |-- index.js
          |-- views
               |-- pages
               |    |-- template.ejs
               |-- partials

Major Changes made to file structure:

  • pages directory moved out of components directory.
  • Replaces .babelrc with a .babelrc.js to create dynamic configuration for babel.

package.json

{
  "scripts": {
    "webpack": "webpack -d",
    "dev": "nodemon --exec 'npm run webpack &&  COMPILER_ENV=server babel-node src/server.js'",
    "webpack-production": "NODE_ENV=production webpack -p",
    "babel-production": "NODE_ENV=production COMPILER_ENV=server babel --out-dir dist src",
    "start": "node dist/server.js",
    "build": "npm run webpack-production && npm run babel-production"
  },
  "author": "smoketrees",
  "license": "MIT",
  "nodemonConfig": {
    "ignore": [
      "src/static/**/*"
    ]
  },
  "homepage": "https://github.com/smoke-trees/forum#readme",
  "devDependencies": {
    "@babel/cli": "^7.8.4",
    "@babel/core": "^7.8.4",
    "@babel/node": "^7.8.4",
    "@babel/plugin-proposal-class-properties": "^7.8.3",
    "@babel/polyfill": "^7.8.3",
    "@babel/preset-env": "^7.8.4",
    "@babel/preset-react": "^7.8.3",
    "babel-loader": "^8.0.6",
    "babel-plugin-file-loader": "^2.0.0",
    "babel-plugin-transform-require-ignore": "^0.1.1",
    "clean-webpack-plugin": "^3.0.0",
    "css-loader": "^3.5.3",
    "file-loader": "^6.0.0",
    "html-webpack-plugin": "^4.3.0",
    "mini-css-extract-plugin": "^0.9.0",
    "nodemon": "^2.0.2",
    "raw-loader": "^4.0.1",
    "webpack": "^4.41.6",
    "webpack-cli": "^3.3.11"
  },
  "dependencies": {
    "body-parser": "^1.19.0",
    "compression": "^1.7.4",
    "ejs": "^3.0.1",
    "express": "^4.17.1",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }
}

src/server.js

import express from "express";
import compression from "compression";
import index from "./routes/index";
import path from "path";

// Server var
const app = express();

// View engine setup
app.set("views", path.join(__dirname, 'static', "views"));
app.set("view engine", "ejs");

// Middleware
app.use(compression());
app.use('/public', express.static(path.join(__dirname, 'static', 'public')));

//Routes
app.use("/", index);

const port = process.env.PORT || 3000;

app.listen(port, function listenHandler() {
    console.info(`Running on ${port}`)
});

A basic express server serving at port 3000.

.babelrc.js

A .babelrc.js file is not much different than a .babelrc.js and default exports an object which will represent the .babelrc file.

const presets =
  [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ],
    [
      "@babel/preset-react"
    ]
  ]

const plugins = [
  "@babel/plugin-proposal-class-properties",

]

if (process.env.COMPILER_ENV === 'server') {
  plugins.push(
    [
      "file-loader",
      {
        "name": "[hash].[ext]",
        "extensions": ["png", "jpg", "jpeg", "gif", "svg"],
        "publicPath": "/public/img",
        "outputPath": null
      },
      "img-file-loader-plugin"
    ],
    [
      "file-loader",
      {
        "name": "[hash].[ext]",
        "extensions": ["css", "sass", "scss"],
        "publicPath": "/public/css",
        "outputPath": null
      },
      "css-file-loader-plugin"
    ],
  )
}

const addConfigs = { ignore: ["./src/static/"] }

module.exports = { plugins, presets, ...addConfigs }

I've added an extra babel plugin to this file called babel-plugin-file-loader. I would recommend going through its README. It is configured here to convert
import styles from 'styles.css'
to
const style='/publicPath/[filehash].css'

It's important to import CSS in an above-mentioned way as simply importing it as import 'styles.css' will throw an error.

Two instances of the plugins have been configured for this plugin one for the images and another for the css files. Output path for these files is set to null as copying these files to final build will be handled by webpack. Option publicPath is to configure the path where the files will be available through the server.

This babel file is used to transpile the code two times one time when it's bundled by webpack and once when we finally transpile our src folder to run using node. When webpack is using the config file we don't want it to use the babel-plugin-file-loader so we've used an environment variable to control when the plugin is used.

webpack.config.js

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const production = process.env.NODE_ENV === 'production'

const pages = ['index']

const generateEntryPoints = (entry) => {
    return entry.reduce((obj, item) => {
        return {
            ...obj,
            [item]: [path.resolve('src', 'components', 'entrypoints', `${item}.jsx`)]
        }
    }, {})
}

const generateHtml = (entry) => {
    return entry.map((i) => {
        return new HtmlWebpackPlugin({
            chunks: [i],
            filename: `../views/pages/${i}.ejs`,
            template: path.join('src', 'views', 'pages', 'template.ejs')
        })

    })
}

const config = [{
    entry: {
        ...generateEntryPoints(pages)
    },

    output: {
        path: production ? path.resolve(__dirname, 'dist', 'static', 'public') : path.resolve(__dirname, 'src', 'static', 'public'),
        filename: production ? 'js/[chunkhash].js' : 'js/[name].js',
        publicPath: '/public'
    },

    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env', '@babel/preset-react'],
                    }
                },
                exclude: [/node_modules/, /static/]
            }, {
                test: /\.ejs$/,
                loader: 'raw-loader'
            }, {
                test: /\.(css)$/,
                use: [{
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                        publicPath: '/public/css'
                    }

                }, 'css-loader']
            }, {
                test: /\.(jpg|jpeg|png|svg|gif)$/,
                use: [{
                    loader: 'file-loader',
                    options: {
                        name: '[md5:hash:hex].[ext]',
                        publicPath: '/public/img',
                        outputPath: 'img',
                    }
                }]
            }
        ]
    },

    resolve: {
        extensions: ['.js', '.jsx', '.json', '.wasm', '.mjs', '*']
    },

    optimization: {
        splitChunks: {
            automaticNameDelimiter: '.',
            cacheGroups: {
                react: {
                    chunks: 'initial',
                }
            }
        }
    },

    plugins: [
        new CleanWebpackPlugin(),
        // create blog,
        new MiniCssExtractPlugin({
            filename: production ? 'css/[contentHash].css' : 'css/[id].css',
            chunkFilename: production ? 'css/[contentHash].css' : 'css/[id].css'
        }),
        // Ejs pages
        ...generateHtml(pages)
    ]
}]

module.exports = config

A lot of plugins have been added to the webpack file and I've written 2 functions to automate the task of creating ejs file.

  • clean-webpack-plugin: This plugin cleans the public folder on each run where the output for the webpack is saved.

  • html-webpack-plugin: HtmlWebpackPlugin plugin is used to create HTML for each page from a template file. The files that are needed to add for the scripts and css will be added automatically for each page depending on its need. I've used raw-loader to load the ejs file as using the default loader of the plugin will cause issues with ejs.

  • mini-css-extract-plugin: MiniCssExtractPlugin is used to extract css from different files and bundle them into different chunks. These chunks will be added automatically thanks to HtmlWebpackPlugin.

  • file-loader: file-loader is used to copy image files into the public folder.

  • babel-loader: babel-loader is used to transpile the React jsx files before bundling.

  • css-loader: css-loader is used to load css files and resolve imports in the jsx files.

I'll cover the two functions generateHtml and generateEntryPoints later on ["Adding more pages"].

src/views/pages/template.ejs

<!DOCTYPE html>
<html lang="en">

<head>
    <title>Smoke -h</title>
</head>

<body>
    <div id="root"><%- reactApp %></div>
</body>

</html>

As I mentioned earlier we need not link css files or js scripts ourselves as it will be handled by HtmlWebpackPlugin.

src/pages/index.jsx

import React from "react";

import global from './global.css'
import index from './index.css'

import img from './img.jpg'

class Index extends React.Component {
    constructor() {
        super();
        this.state = { name: "a", email: "" }
    }


    onFormSubmit = (event) => {
        event.preventDefault();
    }

    onNameChangeHandler = (event) => {
        this.setState({ name: event.target.value });
    }

    onEmailChangeHandler = (event) => {
        this.setState({ email: event.target.value });
    }

    render() {
        return (
            <div>
                <h1 className='heading'>Smoke -h</h1>
                <form onSubmit={this.onFormSubmit}>
                    <div>
                        <label htmlFor={"name-input"} defaultValue={"Name"}>Name: </label>
                        <input name={"name-input"} onChange={this.onNameChangeHandler} type={"text"}
                            value={this.state.name} />
                    </div>
                    <br />
                    <div>
                        <label htmlFor={"email-input"} defaultValue={"Email"}>Email: </label>
                        <input name={"email-input"} onChange={this.onEmailChangeHandler} type={"email"}
                            placeholder={"email"} value={this.state.email} />
                    </div>
                    <br />
                    <div>
                        <button type={"submit"}>Submit</button>
                    </div>
                </form>
                <span><h5>Name: {this.state.name}</h5></span>
                <span><h5>Email: {this.state.email}</h5></span>
                <img src={img} alt='something' />>
            </div >
        )
    }
}

export default Index;

src/components/entrypoint

import React from "react";

import { hydrate } from "react-dom";

import Index from '../../pages/index'


hydrate(<Index />, document.getElementById("root"));

src/routes/index.js

import express from "express";
import React from "react";
import { renderToString } from "react-dom/server"
import Index from "../pages/index"


const router = express.Router();

router.get('/', async (req, res) => {
    const reactComp = renderToString(<Index />);
    res.status(200).render('pages/index', { reactApp: reactComp });
})
export default router;

I've also made an update to previous scripts and now you need to run npm run dev to start the development server. It will put the watch on files using nodemon and re-bundle the files and restart the files automatically on change.

Performing the above steps will give the same result as the previous part 😝 Until now I've mentioned the changes made as compared to the previous post. Now let's move forward with adding another page to the project and adding a stylesheet and an image.

Adding a new page to the project.

Adding a new page to our project consist of a few steps.

1) Adding a JSX file for the page in the src/pages directory

import React, { Component } from 'react'
import aboutStyle from "./about.css" // please don't import as: import './about.css' as it will cause an error due to babel-plugin-file-loader
import img from "./img.jpg"

export class About extends Component {
    render() {
        return (
            <div className='heading'>
                This is the about page
                <div className='subheading'>
                    This is the sub heading
                    <img src={img}/>
                </div>
            </div>
        )
    }
}

export default About    

An image and a css file imported here to demonstrate how we can do that.

2) Add a route handler to express to serve at the new route.
For now, we can do this in the /routes/index.js

import express from "express";
import React from "react";
import { renderToString } from "react-dom/server"
import Index from "../pages/index"
import About from "../pages/about"


const router = express.Router();

router.get('/', async (req, res) => {
    const reactComp = renderToString(<Index />);
    res.status(200).render('pages/index', { reactApp: reactComp });
})
router.get('/about', async (req, res) => {   // Add route /about
    const reactComp = renderToString(<About />);
    res.status(200).render('pages/about', { reactApp: reactComp });
});

export default router;

3) Create an entry point file inside src/components/entrypoint for the new page.

src/components/entrypoint

import React from "react";

import { hydrate } from "react-dom";

import Index from '../../pages/about'

hydrate(<Index />, document.getElementById("root"));

4) Add an element to the pages array in the webpack.config.js file.


...
...

const pages = ['index', 'about']

...
...

Note: The name of the element added to the pages array is the same name as the entrypoint file.

You should add an image as img.jpg and a css file about.css with some styling in the src/pages directory.

How to run

Simply run following command on the terminal

npm run dev

It will put a watch on files and re-bundle the files and restart the server on changes.

How to build for production

npm run build

The above command will create production bundles and compile JSX code to commonjs which can be used run using node.js

Starting production server

npm start

Ending notes

I've created a companion repository for this post. You can use the repository as the base of your project.

I've added a Dockerfile to create an optimized docker image of your application using the production build.

For consuming API you can use ComponentDidMount() lifecycle method in a class component or useEffect hook in a functional component on the Client side. To consume an API on the server-side please wait for my next post.

Top comments (5)

Collapse
 
jeromediver profile image
Jérôme Lanteri

Your automation JS webpack file is difficult to me to understand. Also, when i return renderToString for a Fragment React object, at render view time (i use pug engine), i get a string output in the page instead of the html content.
I would be very happy if you can explain something more than a little resume about html-webpack-plugin.
Actually, on my side, when i read your post, it is like i read directly you source code with resume of entry points i can find everywhere about what you used, but i can not find any real explications about what you are doing when something is happening in the background (like, for example, when build with babel and webpack).

Collapse
 
achhapolia10 profile image
Anshuman Chhapolia

If you are using pug then use this plugin and not the html-webpack-plugin. HTML webpack plugin adds a script to an HTML like document only. It won't work with pug.
npmjs.com/package/html-webpack-pug...
I would suggest going through my posts on medium. I haven't been able to update the post here with the latest structure.
Part 1: medium.com/@achhapolia10/server-si...
Part 2: medium.com/swlh/server-side-render...
I've explained the babel and webpack configs and what is happening in detail.

Collapse
 
jeromediver profile image
Jérôme Lanteri

I understand better, i was feeling it can be something like that, but you confirm to me that's it.
Thank you very much for your answer (and so quick also). I do appreciate a lot.
I'm pretty very new on React.js and yes, i'm very entousiast to touch this and learn. I decide to not use simplicity with Next.js to understand better what's happening first.
Thank you very much @anshuman Chhapolia

Collapse
 
achhapolia10 profile image
Anshuman Chhapolia

If you want to use the latest code its available as a template on github:
github.com/smoke-trees/ssr-react

Collapse
 
jeromediver profile image
Jérôme Lanteri

Thank you, i will read it carefully to learn better, then try in the same time by do my own code. I will also push it on github next, and will use next.js on next level learning step.