On a recent project, we used Create React App (CRA) as a starting point. It provides a lot of features out of the box that make starting a new React project a breeze, and it has a community-driven update path.
I also wanted to share Typescript code between the front end and back end, something I’d come to appreciate from working with Atomic’s SPA starter kit. Unfortunately, this code sharing is missing from CRA because it’s designed to be used only for front-end development. CRA also doesn’t make it easy to do this without ejecting since it hides the webpack
config that it uses in the react-scripts
node module.
I wanted to preserve the update path that CRA provides, so I decided to set up an application that can share code between a CRA front end and an Express back end without ejecting or modifying react-scripts
.
This solution allows us to rapidly iterate on our back end and front end while sharing code between the two. The full sample code is available on github.
1. Set Up the Front End
Create your application using the create-react-app
CLI.
npx create-react-app cra-express --template typescript
cd cra-express
Create a client
directory in src
. This will contain all of your code that’s specific to the front end.
mkdir src/client
Move every file in the src
directory to the src/client
directory except for index.tsx
, react-app-env.d.ts
, and setupTests.ts
. These files are client specific; however, CRA requires them to be in the root of the src
directory, so we cannot put them in the client
directory.
mv src/index.css src/App.css src/App.tsx src/App.test.tsx src/logo.svg src/serviceWorker.ts src/client
Update your imports in index.tsx
by changing
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
to
import './client/index.css';
import App from './client/App';
import * as serviceWorker from './client/serviceWorker';
At this point, you should have the same app that CRA makes for you, just with a slightly different folder structure. You should run yarn start
just to verify that your project is working as expected.
2. Set Up the Back End
Install the necessary dependencies.
yarn add express body-parser @types/express @types/body-parser
Make a src/server
directory with an index.ts
file that will contain your Express server.
mkdir src/server
Put the following contents into the src/server/index.ts
file:
import express from "express";
import bodyParser from "body-parser";
import path from "path";
const buildDir = path.join(process.cwd() + "/build");
const app = express();
app.use(bodyParser.json());
app.use(
bodyParser.urlencoded({
extended: true,
})
);
app.use(express.static(buildDir));
app.get("/*", function (req, res) {
res.sendFile(path.join(buildDir, "index.html"));
});
const port = 3001;
console.log("checking port", port);
app.listen(port, () => {
console.log(`Server now listening on port: ${port}`);
});
If you’re using a text editor with Typescript support, you will most likely see type errors. To fix this, add a tsconfig.json for your server code in the src/server
directory. This accomplishes a few things:
- It allows you to use language features that aren’t supported on the front end.
- Putting a
tsconfig.json
in thesrc/server
causes the Typescript compiler to use different settings for code in theserver
andclient
directories. - It allows you to exclude client code from the server build (and vice versa) using the
”exclude"
key.
Put the following into the src/server/tsconfig.json
:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../dist",
"noEmit": false,
"sourceMap": true,
"module": "esnext",
"strict": true,
"target": "esnext",
"moduleResolution": "node"
},
"exclude": ["../../build", "../client"],
"include": ["."]
}
Update the tsconfig.json
in the root directory to include:
{
...
// Don't compile src/server on the client
"exclude": [
"src/server"
]
}
3. Set Up Webpack
Before creating a webpack
config, install a few dependencies:
webpack-cli
allows us to runwebpack
commands from the command line.webpack-node-externals
will keepwebpack
from bundling ournode_modules
.ts-loader
allowswebpack
to compile Typescript.nodemon
will allow our server to reload whenever changes are made to the server.concurrently
allows us to run multiple commands at once from the command line.wait-on
will allow our scripts to wait for a resource to be available before running. In this case, we wantnodemon
to wait forwebpack
to create our build file, and we want CRA to wait for our server port to be open.
yarn add webpack-cli webpack-node-externals ts-loader nodemon concurrently wait-on
Create a webpack.config.server.js
file with the following contents:
const path = require("path");
const nodeExternals = require("webpack-node-externals");
const entry = { server: "./src/server/index.ts" };
module.exports = {
mode: process.env.NODE_ENV ? process.env.NODE_ENV : "development",
target: "node",
devtool: "inline-source-map",
entry: entry,
output: {
path: path.resolve(__dirname, "build"),
filename: "[name].js",
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
// don't compile node_modules
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
// use the tsconfig in the server directory
configFile: "src/server/tsconfig.json",
},
},
],
},
],
},
};
Update your package.json
file to include scripts to build and run the app.
{
...
"scripts": {
"test": "react-scripts test",
"eject": "react-scripts eject",
"dev": "concurrently -p name -c \"yellow,magenta,blue\" -n \"webpack-server,nodemon-server,CRA\" \"yarn dev:server:webpack\" \"yarn dev:server:nodemon\" \"yarn dev:client\"",
"dev:client": "wait-on -l tcp:3001 && react-scripts start",
"dev:server": "concurrently -p name -c \"yellow,magenta\" -n \"webpack-server,nodemon-server\" \"yarn dev:server:webpack\" \"yarn dev:server:nodemon\"",
"dev:server:webpack": "webpack --config webpack.config.server.js --watch",
"dev:server:nodemon": "rm -f build/server.js && wait-on -l build/server.js && nodemon build/server.js",
"build": "yarn build:client && yarn build:backend",
"build:client": "react-scripts build",
"build:server": "webpack --config webpack.config.server.js"
},
...
}
We now have commands to build and run our client and server! To see both working together, run yarn dev
.
4. Bring it All Together
Now let’s link our front end and back end together with some shared code. Add this key to the root of your package.json
:
...
"proxy": "http://localhost:3001”,
...
This allows our front end to communicate with our back end without specifying a complete route.
// The localhost:3001 prefix is not needed
fetch("localhost:3001/api/ping")
// This will hit the express server
fetch("/api/ping")
Add a src/core
directory for your shared code.
mkdir src/core
For this example, we will add a math.ts
file that exports a sum function:
export const sum = (a: number, b: number) => a + b;
Add the following endpoint to src/server/index.ts
:
import { sum } from "../core/math";
...
app.get("/ping", function (req, res) {
return res.json(`${sum(10, 4)}`);
});
...
Update src/client/App.tsx
to communicate with your new endpoint and call the sum
function:
import React, { useState, useEffect } from "react";
import logo from "./logo.svg";
import "./App.css";
import { sum } from "../core/math";
function App() {
const [serverResult, setServerResult] = useState(null);
useEffect(() => {
(async () => {
const result = await fetch("/ping");
const newServerResult = await result.json();
setServerResult(newServerResult);
})();
}, []);
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Client result {sum(1, 3)}</p>
<p>Server result {serverResult}</p>
</header>
</div>
);
}
export default App
And that’s it! When you run yarn dev
, you will see the client sum will be called and the server sum will be called!
This was extremely helpful! Can you expand on this and show how to run tests for both the FE and BE as well as what are the required steps to build and publish the application. Thanks