Getting Create React App & Express to Share Code Without Ejecting (Using Webpack & Typescript)

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 the src/server causes the Typescript compiler to use different settings for code in the server and client 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 run webpack commands from the command line.
  • webpack-node-externals will keep webpack from bundling our node_modules.
  • ts-loader allows webpack 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 want nodemon to wait for webpack 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!

Conversation
  • Matthew Smithson says:

    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

  • Comments are closed.