DEV Community

Janice
Janice

Posted on • Updated on

Create React App From Scratch With Isomorphic Rendering

I'd been always curious about how to manually configure a React project from scratch using webpack. Let me share my learnings with you in this post.

The benefits of learning configuring your own project:

  1. Prodviding you a bare bone to start with when create-react-app is an overkill because it includes many redundant functionalities.
  2. Learning about how babel transpiles your project, how it works with typescript during development and how to auto build your project along with changes.
  3. Mastering webpack configurations to build your own library in the future.

Isomorphic rendering/Server-side rendering

SSR can greatly improve user's first interaction experience by optimizing the first page load time. It also provides a better search engine experience where the crawl bot can access more meaningful content in your pre-generated html file whereas a pure client-side rendering page only contains <div id="root"></div>.

Project Structure

After building our project, we are expecting a dist/ folder containing client/ and server/ where client/ contains the code for hydrating the pre-generated content and server/ contains the server code with NodeJS. Hence, we need two webpack.config.js for configuring each directory respectively. Later on, we can boost the server by running node dist/server/main.js and see the page result by accessing the localhost url. Here is the preview:

>> dist
>>>> client
>>>> server
>> node_modules
>> server
>>>> ...
>>>> webpack.config.js (builds dist/server)
>> src (components)
>> client.tsx (clients-side entry file)
>> webpack.config.js (builds dist/client)
Enter fullscreen mode Exit fullscreen mode

Client side logic

The code below tells React to compare the pre-generated content from server to the component App we want to render. Any mismatch is not allowed because SSR provides users an illusion of faster page load time by showing users content generated from server, then attaches React to it. Any mismatch between the content will break that illusion.

import { hydrateRoot } from 'react-dom/client';
import { App } from './src/app';
import React from 'react';

hydrateRoot(document.querySelector('#root')!, <App />);
Enter fullscreen mode Exit fullscreen mode

In the webpack.config.js, we have HtmlWebpackPlugin configured to automatically inject the built scripts to the html template we provide, which is a simple boilerplate includes <div id="root"></div>. filename: '[name].[contenthash].js' is to improve compile time by preventing re-compling files if there is no changes since last build.

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const config = {
    entry: './client.tsx',
    output: {
        filename: '[name].[contenthash].js',
        path: path.resolve(__dirname, 'dist/client'),
        // clean /dist/client before each build
        clean: true,
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.json'],
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Create React From Scratch',
            meta: { viewport: 'width=device-width, initial-scale=1.0' },
            template: './index.html',
            inject: 'body',
            publicPath: '/dist/client',
        }),
    ],
    module: {
        rules: [
            {
                test: /\.(ts|js)x?$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                },
            },
        ],
    },
    optimization: {
        // to prevent vendor chunk from being regenerated due to module resolving order is changed
        // when a new dependency is included
        moduleIds: 'deterministic',
        // extract the boilerplate for webpack runtime code
        runtimeChunk: 'single',
        // remove duplicate dependencies from bundles
        splitChunks: {
            chunks: 'all',
        },
    },
};

module.exports = (env, argv) => {
    if (argv.mode === 'development') {
        config.devtool = 'inline-source-map';
    }

    return config;
};
Enter fullscreen mode Exit fullscreen mode

babel-loader is a handy plugin for transpiling js code so that the resultant code is compatible across older browser environments. node_modules is excluded because we are using packages actively maintained by the community thus we can expect them to be in a good shape for production use. In addition, we have babel-loader configured with a list of presets. Those presets provide pre-defined lists based on the target environment for transpiling js, jsx and ts code.

babel.config.json

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react",
        "@babel/preset-typescript"
    ]
}
Enter fullscreen mode Exit fullscreen mode

Server side logic

Now we have our html file ready with necessary scripts injected. The next objective is to inject the html string of the component we want to render into the html file. Here is when renderToString comes into play. renderToString renders our React component into a html string.

// template.tsx
import { renderToString } from 'react-dom/server';
import { App } from '../src/app';
import React from 'react';

export default function renderHtmlString() {
    return renderToString(<App />);
}
Enter fullscreen mode Exit fullscreen mode

In server.ts, parse the html read from the file system and inject the rendered html string into div#root.

// server.ts
import fs from 'fs';
import renderHtmlString from './template';
import express from 'express';
import { parse } from 'node-html-parser';
import path from 'path';

// ...
app.get('/', async (_, res) => {
    try {
        const html = fs.readFileSync(
            path.resolve(process.cwd(), 'dist/client/index.html'),
            'utf-8'
        );
        const htmlContent = parse(html);
        const root = htmlContent.querySelector('#root')!;
        root.textContent = renderHtmlString();
        res.send(htmlContent.toString());
    } catch (err) {
        throw err;
    }
});
Enter fullscreen mode Exit fullscreen mode

In webpack.config.js, it is worth mentioning that we use webpack-node-externals to exclude packages from node_modules and externalsPresets: { node: true } to exclude native packages provided by the environment from being bundled into our code.

const path = require('path');
const nodeExternals = require('webpack-node-externals');

const config = {
    target: 'node',
    entry: path.resolve(__dirname, 'server.ts'),
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, '../dist/server'),
        // clean /dist before each build
        clean: true,
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js', '.json'],
    },
    plugins: [],
    module: {
        rules: [
            {
                test: /\.(ts|js)x?$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                },
            },
        ],
    },
    // in order to ignore built-in modules like path, fs, etc.
    externalsPresets: { node: true },
    // in order to ignore all modules in node_modules folder
    externals: [nodeExternals()],
    optimization: {
        // to prevent vendor chunk from being regenerated due to module resolving order is changed
        // when a new dependency is included
        moduleIds: 'deterministic',
        // extract the boilerplate for webpack runtime code
        runtimeChunk: 'single',
        // remove duplicate dependencies from bundles
        splitChunks: {
            chunks: 'all',
        },
    },
};

module.exports = (env, argv) => {
    if (argv.mode === 'development') {
        config.devtool = 'inline-source-map';
    }

    return config;
};
Enter fullscreen mode Exit fullscreen mode

Using ECMAScript in Node

You may be wondering how to configure to use ESM imports in Node. Most people suggest to add "type": "module" in package.json, which requires to explicitly name the file extension while importing the scripts. We can avoid that by adding a tsconfig.json under server/ to support ESM imports. You may find this file isn't necessary since babel has support for ts syntax. Babel also doesn't compile options from tsconfig. However, this step is essential if you are using ts-node in your development because ts-node reads from tsconfig file. It wouldn't have recogonised ESM import syntax in otherwise.

{
    "compilerOptions": {
        "target": "esnext",
        "jsx": "react" ,
         /* supports both ECMAScript imports and CommonJS require */
        "module": "NodeNext" ,
    }
}
Enter fullscreen mode Exit fullscreen mode

Babel transpiles your code without type checking, type check your code with typescript

Babel transpiles your code without type checking, which can slip runtime errors into production. In order to prevent that, we should type check our code during build time or even type check in watch mode during development. Here is the tsconfig.json on client side, where noEmit is specified for only type checking purpose.

{
    "compilerOptions": {
        "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
        "jsx": "react" /* Specify what JSX code is generated. */,
        "module": "commonjs" /* Specify what module code is generated. */,
        "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
        "strict": true /* Enable all strict type-checking options. */,

        /* Babel does not type check the code, use tsc for type checking */
        "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
        "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
        "noEmit": true
    }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json on server side

{
    "compilerOptions": {
        "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
        "jsx": "react" /* Specify what JSX code is generated. */,
        "module": "NodeNext" /* supports both ECMAScript imports and CommonJS require */,
        "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
        "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
        "strict": true /* Enable all strict type-checking options. */,
        "moduleResolution": "NodeNext" /* in sync with "module" config */,

        /* Babel does not type check the code, use tsc for type checking */
        "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
        "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
        "noEmit": true
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, putting everything together in package.json

"scripts": {
        "type-check": "tsc",
        "type-check:watch": "tsc --watch",
        "build:client": "npm run type-check && webpack --mode=production --config webpack.config.js",
        "build:server": "npm run type-check && webpack --mode=production --config server/webpack.config.js --stats-error-details",
        "build": "npm run build:client && npm run build:server",
        "dev:watch": "webpack watch --mode=development --config server/webpack.config.js --config webpack.config.js",
        "prod:watch": "webpack watch --mode=production --config server/webpack.config.js --config webpack.config.js",
        "server": "nodemon dist/server/main.js"
    },
Enter fullscreen mode Exit fullscreen mode

Git repo: https://github.com/qianzhong516/create-react-app-from-scratch/tree/isomorphic-react

Top comments (0)