Configure multiple entry points for Create React App without the eject
Create React App is an opinionated and officially supported tool to set up a React application with no configuration. It comes with some tradeoffs - the build configuration is hidden and you have to use npm run eject
script to edit this configuration. In this case, you'll stay one-on-one with fighting build configuration and dependencies mismatch during an upgrade.
Having multiple entry points is one of the reasons why people usually decide to maintain their own build configuration instead of using CRA. In this post, we'll see how to configure multiple entry points without eject using Webpack dynamic imports.
Suppose you'd like to have the following project structure:
src/ - admin/ - index.js - client/ - index.js - shared/ - index.js
- 2 separate builds for the client app and the admin app. The code from one app shouldn't appear in the code of another app.
- Both apps can reuse code from a shared folder
npx create-react-app multiple-entry-points && cd multiple-entry-points
Verify it's up and running by:
npm run start
Step 1. Prepare the folder structure
Let's create all the needed folders and files using Bash's path expansion:
mkdir src/{client,admin,shared} touch src/{client,admin,shared}/index.jsx
Now if you run tree src/ -tr
you'll see the following structure:
src βββ shared βΒ Β βββ index.jsx βββ client βΒ Β βββ index.jsx βββ admin βΒ Β βββ index.jsx βββ setupTests.js βββ reportWebVitals.js βββ logo.svg βββ index.js βββ index.css βββ App.test.js βββ App.js βββ App.css
Step 2. Write React components
Now we can add basic components and a shared function that can be used in both apps.
// src/shared/index.jsx: export const getSharedData = () => 'shared code'
// src/admin/index.jsx import React from 'react'; import { getSharedData } from "../shared"; export const App = () => <h1>Admin app {getSharedData()}</h1>
// src/client/index.jsx import React from 'react'; import { getSharedData } from "../shared"; export const App = () => <h1>Client app {getSharedData()}</h1>
Step 3. Set up the index files
We are going to create an index file that loads our apps using the dynamic imports. Dynamic imports is a perfect fit if you wish to load a module conditionally or on demand. Let's see how to use them. Suppose you have a module sum.js
:
export const sum = (a, b) => a + b;
Then you can dynamically load it:
import(`./sum.js`).then(({ sum }) => { console.log(sum(1, 2)); });
There are some restrictions about this syntax:
- Import path must start with
./
or../.
- Our modules should belong to the same directory
- An import path may contain a concatenation with a variable. Example:
import(./translation/${language})
. During the build a bundler gathers all the files in thetranslation
directory and replaces this code with something like this:
const loadDynamically = (path) => { switch (path) { case 'en.js': return import('./translation/en.js'); case 'de.js': return import('./translation/de.js'); ... } } loadDynamically(language)
It shouldn't be possible for a bundler to load an arbitrary file from a file system. That's why these limitations exist.
So for our project we'd need something like this in the src/index.js
import ReactDOM from 'react-dom'; const getIndexFile = () => { // Determine index file somehow } import(`./index/${getIndexFile()}`).then((module) => { // Use ReactDOM.render to render components })
As I said before our modules should belong to the same directory. But files `admin/index.js` and `client/index.js` are located in different directories. Let's create a folder with 2 files - a file to import the admin project and a file to import the client project:
mkdir src/index touch src/index/{index-client.jsx,index-admin.jsx}
// src/index/index-admin.jsx import { App } from "../admin" export const render = () => <App/>
// src/index/index-client.jsx import { App } from "../client" export const render = () => <App/>
Then we can hardcode the returning value of getIndexFile
and test the build for the first time:
import ReactDOM from 'react-dom'; const getIndexFile = () => { return 'index-admin' } import(`./index/${getIndexFile()}`).then(({ render }) => { ReactDOM.render(render, document.getElementById('root')) })
It shows a page with "Admin app shared" text. Let's add console.log
statements to make sure the code from the client
app doesn't appear in the bundle:
// src/admin/index.jsx // ... console.log('admin')
// src/client/index.jsx // ... console.log('client')
Now if you re-run the server you'll see only admin
log statement in the browser console.
Step 4. Determine which app to load
The only thing left is to determine what getIndexFile
function should return. There are many ways how to achieve it. For example we can check the current hostname or use environment variables. The truth is that you better use both of them to speed up your CI and have a good developer experience. Let's start with environment variables:
import ReactDOM from 'react-dom'; const getIndexFile = () => { const environments = { ADMIN: 'index-admin', CLIENT: 'index-client', } const buildTarget = process.env.REACT_APP_BUILD_TARGET; const result = environments[buildTarget] // A check to avoid typo if (!result) { throw new Error(`Incorrect REACT_APP_BUILD_TARGET varaible: ${buildTarget}`); } return result; } import(`./index/${getIndexFile()}`).then(({ render }) => { ReactDOM.render(render, document.getElementById('root')) })
Here we determine which module to load based on our custom environment variable. CRA requires to prefix custom environment variables with REACT_APP_
Now we can add 2 scripts to package.json
for easy switching between the environments:
"scripts": { "start:admin": "REACT_APP_BUILD_TARGET=ADMIN react-scripts start", "start:client": "REACT_APP_BUILD_TARGET=CLIENT react-scripts start",
Feel free to test it and switch between the projects. If you'd like to run these project at the same time you can specify the port to avoid conflicts between Node.js processes:
"scripts": { "start:admin": "PORT=3001 REACT_APP_BUILD_TARGET=ADMIN react-scripts start", "start:client": "REACT_APP_BUILD_TARGET=CLIENT react-scripts start",
Step 5. Build the app
You can go further and use the same environment-based approach to build your app. In this could you'd need 2 more scripts in your package.json
"scripts": { "build:admin": "REACT_APP_BUILD_TARGET=ADMIN react-scripts build", "build:client": "REACT_APP_BUILD_TARGET=CLIENT react-scripts build",
This approach works fine except it requires to make 2 builds on CI which is time-consuming and not effective as it could. To avoid it you can determine which app to load in production based on current hostname. This is how the complete version of src/index.js
looks like:
import ReactDOM from 'react-dom'; const getIndexFile = () => { const environments = { ADMIN: 'index-admin', CLIENT: 'index-client', } const buildTarget = process.env.REACT_APP_BUILD_TARGET; if (buildTarget) { const result = environments[buildTarget] // A check to avoid typo if (!result) { throw new Error(`Incorrect REACT_APP_BUILD_TARGET: ${buildTarget}`) } return result } switch (window.location.hostname) { case 'admin.project.com': return environments.ADMIN case 'client.project.com': return environments.CLIENT default: throw new Error(`Unknown host ${window.location.hostname}`) } } import(`./index/${getIndexFile()}`).then(({ render }) => { ReactDOM.render(render, document.getElementById('root')) })
It is enough to build it once on CI:
"scripts": { "build": "react-scripts build"
Conclusion
We've configured CRA to have multiple entry points. Both apps don't interfere with each other and can reuse shared code. Our CI is still fast because we build the app only once. The source code for the demo is available here: https://github.com/kubk/blog/tree/master/demo/multiple-entry-points