How to Extend Create-React-App for Multi-Page Applications
Create-React-App
Most React applications are brought into the world by running the create-react-app command.
npx create-react-app some-app-name
Or, if you’re using Typescript:
npx create-react-app some-app-name --template typescript
The resulting starter application can then be served (by an ‘internal’ development web server) and viewed in the browser by typing the command:
npm start
Create-react-app generates an environment for application development with the basic plumbing in place to handle many aspects of web application set-up that would be time-consuming for the developer to handle him or herself.
The ‘start’ script we executed above is part of the environment generated by create-react-app and handles development mode application start-up tasks like bundling, code injection, starting up the development server, and many more. Currently, the start script can’t handle applications with more than a single page — hence this article — how to expand start.js processing so that it can.
Why multiple entry points?
The environment generated by create-react-app support only a single page because create-react-app is specifically designed to create a React SPA (Single Page Application). There are however lots of scenarios where one or more additional pages would be useful. For example, a separate page might be justified for a particularly javascript-heavy page to prevent the load-time of the rest of the application from being negatively impacted.
We encountered another such scenario recently where we had an application — a designer with an embedded renderer — and we needed to provide a standalone renderer. Although a separate application would have been justified by some major differences between the applications, there was too much shared code to make that approach the most efficient solution. We decided on a configuration-driven approach to supporting additional pages, to provide maximum flexibility in application design.
Extending the application start-up code
This article will show how to extend the processing of the ‘start’ script generated by create-react-app so that, in development mode, an application with more than one page (multiple entry points) can be handled.
There’s a working demo of the final multi-page application here on Github . Clone that repository from the Code tab at that URL or from the command line:
git clone https://github.com/stefarr/create-react-app-multipage.git
Install dependencies and then run with:
npm install
npm start
We’ll reproduce the demo application starting from the starter application generated by create-react-app and then walk through the changes we need to make to it in order to reproduce the demo multi-page application.
In addition to making changes to some files, we’ll need to add some new files (like the additional html pages). For the sake of brevity, we won’t list the content of those files because they’re available in the demo application or from Github.
Create the application
Let’s create the starter application by running create-react-app with the typescript option.
npx create-react-app cra-multipage --template typescript
If you run the application now by typing npm start
, you should see the familiar spinning React logo. So, we have a working single page application.
Eject the app
To make any changes to the scripts provided by create-react-app we need to ‘eject’ first, which we do by running the eject script.
npm run eject
On doing that, you should notice a couple of new folders in the root folder: namely scripts and config. These folders contains code copied from the original react-scripts in node_modules: the scripts folder contains the start.js, build.js and test.js scripts, and the config folder contains other code used by those scripts.
If you run the application again by typing npm start
, you should see the same spinning logo — so we haven’t broken anything by ejecting.
Add files for the new pages we’re adding
These files are available from the demo application or from Github.
\public
The new html pages.
- other1.html
- other2.html
\src\otherpage1
The new AppOther1 component and indexother1 file, that will populate other1.html .
- AppOther1.tsx
- Indexother1.tsx
\src\otherpage2
The new AppOther2 component and indexother2 file that will populate other2.html .
- AppOther2.tsx
- indexOther2.tsx
Configure the additional entry points
We’ll add a couple more new files to the config folder:
/config
- entrypoints.json
- entrypaths.js
The entrypoints.json file is the configuration file which defines all the named entry points (pages) that we want in our application. In the profile section, each named profile defines the pages that should be available in that profile and specifies the page the browser should open on application start-up (the first page listed in the array for that profile).
Entrypaths.js makes the configuration data in entrypoints.json available to various consumers in the application start-up process.
entrypoints.json configuration file
We’ll list the entrypoints.json file below and go over its structure
{
"files":{
"index": {
"folder": "/",
"js": "src/indexmain",
"html": "public/index.html"
},
"other1": {
"folder": "/otherpage1",
"js": "src/otherpage1/indexother1",
"html": "public/other1.html"
},
"other2": {
"folder": "/otherpage2",
"js": "src/otherpage2/indexother2",
"html": "public/other2.html"
}
},
"profiles":
{
"mainpage": ["index"],
"otherpage1": ["other1"],
"otherpage2": ["other2"],
"test": ["other1", "other2","index"]
}
}
The currently active profile will be specified by the APP_PROFILE environment variable whose value will be read from a .env file located in the root folder. The .env file has the following contents initially:
\.env
APP_PROFILE=test
This setting indicates that we want the currently active profile to be the ‘test’ profile from entrypoints.json
{ ...
profiles:{
...
“test”: [“other1”, “other2”,”index”]
which specifies that the browser should initially open the ‘other1’ page but that the ‘other2’ and ‘index’ pages should also be available (e.g. for manual navigation).
We’ll be using the dotenv library to handle environment variables so we’ll need to install it:
npm install dotenv
While we’re at it, we’ll also be using the d3 library to provide a bit of interest and colour to the pages we’re going to add to our application, so let’s install that:
npm install d3
Make changes to existing script files
Let’s go through the changes we need to make to existing files in the config folder:
config/paths.js
Extend paths.js to export functions from entrypaths.js to make configuration data in entrypoints.json available.
Change From:
module.exports.moduleFileExtensions = moduleFileExtensions;
To:
const {
getEntryPointPaths,
getRequiredFilesList,
getBrowserFolder,
getWebPackPlugins,
getEntryPoints,
} = require(“./entrypaths”);const entrypoints = getEntryPointPaths(resolveApp, resolveModule);module.exports.getEntryPoints = () => getEntryPoints(entrypoints);module.exports.getRequiredFiles = () => getRequiredFilesList(getEntryPoints(entrypoints));module.exports.getBrowserFolder = () => getBrowserFolder(getEntryPoints(entrypoints));module.exports.getWebPackPlugins = () => getWebPackPlugins(getEntryPoints(entrypoints));module.exports.moduleFileExtensions = moduleFileExtensions;
config/webpack-config.js
Make modifications to the webpack configuration in webpack-config.js
- Under the ‘entry’ key, create a property for each configured named entry point
Change From:
entry:isEnvDevelopment && !shouldUseReactRefresh ? [webpackDevClientEntry, paths.appIndexJs]:
[paths.appIndexJs]
To:
entry: isEnvDevelopment && !shouldUseReactRefresh?
Object.assign(
{},
...paths.getEntryPoints().map((m) => {
return {[m[0]]:[
webpackDevClientEntry,
Object.entries(m[1])
.find((e) => e[0] === "js")[1]
]
};
})
):
Object.assign(
{},
...paths.getEntryPoints().map((m) => {
return {[m[0]]: [
Object.entries(m[1])
.find((e) => e[0] === "js")[1]
],
};
})
),
2. Under the ‘output’ key, create unique filenames for bundles based on the entry name
Change From:
output: {
path: isEnvProduction ? paths.appBuild : undefined,
pathinfo: isEnvDevelopment,
filename: isEnvProduction
?‘static/js/[name].[contenthash:8].js’
: isEnvDevelopment && ‘static/js/bundle.js’,
To:
output: {
path: isEnvProduction ? paths.appBuild : undefined,
pathinfo: isEnvDevelopment,
filename: isEnvProduction
? 'static/js/[name].[contenthash:8].js'
: isEnvDevelopment && 'static/js/[name].bundle.js',
3. Under the ‘plugins’ key, create an HtmlWebpackPlugin for each configured html page.
Change From:
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}: undefined
)
),
To:
plugins: [
...paths.getWebPackPlugins().map((m) => {
return new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
chunks: [m.chunkname],
template: m.template,
filename: m.filename,
},
isEnvProduction ? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
} : undefined
)
);
}),
4. Under the ‘plugins’ key, create a ManifestPlugin for each configured html page
Change From:
new ManifestPlugin({
fileName: 'asset-manifest.json',
publicPath: paths.publicUrlOrPath,
generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed); const entrypointFiles = entrypoints.main.filter(
fileName => !fileName.endsWith('.map')); return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
}),
To:
...paths.getEntryPoints().map((m)=>{
return new ManifestPlugin({
fileName: `${m[0]}_asset_manifest.json`,
publicPath: paths.publicUrlOrPath, generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed); const entrypointFiles = Object.entries(entrypoints)
.find((f)=>f[0]===m[0])[1].filter(
fileName => !fileName.endsWith('.map')); return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
})
}),
webpack-DevServer.config.js
Change the configuration for webpack-DevServer.config.js
- Create re-write rules for each additional index folder
Change From:
historyApiFallback: {
disableDotRule: true,
index: paths.publicUrlOrPath,
},
To:
historyApiFallback: {
disableDotRule: true,
rewrites: paths.getEntryPoints()
.filter((f) => f[1].folder !== "/").map((m) => {
return {
from: RegExp(`^${m[1].folder}`),
to: `/${m[1].htmlname}` }; }),
index: paths.publicUrlOrPath,
},
Run the application
With all the changes in place , let’s run the application.
npm start
We should see the application start up with localhost:3000/otherpage1 opened in the browser, displaying some graphic ‘artwork’ generated using the d3 library.
We should be able to navigate to other pages of the application. At localhost:3000 URL we should see the original React homepage and at localhost:3000/otherpage2 we should see a slightly different version of the otherpage1 graphic.
So we have multiple pages in our React application and we can navigate between them.
Conclusion
We’ve shown here how the application start-up process initiated by running the start.js script generated by create-react-app can be transformed into a configuration-driven process supporting multiple entry points.