DEV Community

loading...
Cover image for Using Expressjs as Backend for Create React App using Docker Compose

Using Expressjs as Backend for Create React App using Docker Compose

murat
Updated on ・12 min read

Creating an app using reactjs is really fascinating. You see it running on your developer machine and you're done! Really? Now you need to think about packaging, deployment, handling environment variables and sending request to your own backend. Here we'll be going through these steps. Not going into the details of creating a Reactjs app. Completed application is in Github repo.
Main motivation of creating such a development environment is to keep Create React App (CRA) intact and avoid creating external dependencies to any serverside technology. We'll sum up this consideration at the end.

Project Creation

My nodejs version is 14.17.5

We'll create our Reactjs project with famous CRA starter;
npx create-react-app cra-expressjs-docker --template typescript

We'll use Material-Ui for a bare minimum ui design;
npm i @material-ui/core

Let's add React-Router for page navigation;
npm i react-router-dom @types/react-router-dom

Need to add axios for http requests and react-json-view to display a javascript object
npm i axios react-json-view

Let's add pages;

src/pages/Greetings.tsx

import {
  Button,
  createStyles,
  Grid,
  makeStyles,
  Theme,
  Typography,
} from "@material-ui/core";
import TextField from "@material-ui/core/TextField";
import { useState } from "react";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    grid: {
      margin: 20,
    },
    message: {
      margin: 20,
    },
  })
);

const Greetings = () => {
  const classes = useStyles({});
  return (
    <Grid
      className={classes.grid}
      container
      direction="column"
      alignItems="flex-start"
      spacing={8}
    >
      <Grid item>
        <TextField variant="outlined" size="small" label="Name"></TextField>
      </Grid>
      <Grid item container direction="row" alignItems="center">
        <Button variant="contained" color="primary">
          Say Hello
        </Button>
      </Grid>
    </Grid>
  );
};

export default Greetings;


Enter fullscreen mode Exit fullscreen mode

src/pages/Home.tsx

import {
  createStyles,
  Grid,
  makeStyles,
  Theme,
  Typography,
} from "@material-ui/core";
import React from "react";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    grid: {
      margin: 20,
    },
  })
);

const Home = () => {
  const classes = useStyles({});
  return (
    <Grid className={classes.grid} container direction="row" justify="center">
      <Typography color="textSecondary" variant="h2">
        Welcome to Fancy Greetings App!
      </Typography>
    </Grid>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

and update App.tsx like below;
src/App.tsx

import {
  AppBar,
  createStyles,
  makeStyles,
  Theme,
  Toolbar,
} from "@material-ui/core";
import { BrowserRouter, Link, Route, Switch } from "react-router-dom";
import Greetings from "./pages/Greetings";
import Home from "./pages/Home";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    href: {
      margin: 20,
      color: "white",
    },
  })
);

const App = () => {
  const classes = useStyles({});
  return (
    <BrowserRouter>
      <AppBar position="static">
        <Toolbar>
          <Link className={classes.href} to="/">
            Home
          </Link>
          <Link className={classes.href} to="/greetings">
            Greetings
          </Link>
        </Toolbar>
      </AppBar>
      <Switch>
        <Route path="/greetings">
          <Greetings />
        </Route>
        <Route exact path="/">
          <Home />
        </Route>
      </Switch>
    </BrowserRouter>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Now our Reactjs app is ready. Although it lacks greetings funtionalities yet, you can still navigate between pages.
init_app

Adding GraphQL Code Generator

Although we're not going to add a GraphQL server for the time being, we can use GraphQL Code Generator to generate types to be used both in client side and also in server side. GraphQL Code Generator is a wonderful tool and definitely worth to get used to.

Let's install necessary packages, npm i @apollo/client@3.3.16 graphql@15.5.2

npm i --save-dev @graphql-codegen/add@2.0.1 @graphql-codegen/cli@1.17.10 @graphql-codegen/typescript@1.17.10 @graphql-codegen/typescript-operations@1.17.8 @graphql-codegen/typescript-react-apollo@2.0.7 @graphql-codegen/typescript-resolvers@1.17.10

Let's create two files;
codegen.yml

overwrite: true
generates:
  ./src/graphql/types.tsx:
    schema: client-schema.graphql
    plugins:
      - add:
          content: "/* eslint-disable */"
      - typescript
      - typescript-operations
      - typescript-react-apollo
      - typescript-resolvers
    config:
      withHOC: false
      withHooks: true
      withComponent: false
Enter fullscreen mode Exit fullscreen mode

client-schema.graphql

type DemoVisitor {
  name: String!
  id: Int!
  message: String
}
Enter fullscreen mode Exit fullscreen mode

also need to add "codegen": "gql-gen" to scripts part in our package.json

Now we can run codegenerator with npm run codegen

Adding Exressjs serverside using typescript

Create a server directory in the root directory and npm init -y there. Then install the packages;

npm i express ts-node typescript
npm i -D @types/express @types/node nodemon

Since our server code is in typescript, it needs to be compiled to javascript. So, we need to instruct typescript compiler (tsc) somehow. You can do this by giving inline cli parameters. However, a more elegant way is to add a tsconfig file.

server/tsconfig.json

{
  "compilerOptions": {
    "jsx": "react",
    "target": "es6",
    "module": "commonjs",
    "sourceMap": true,
    "outDir": "dist",
    "rootDirs": ["./", "../src/graphql"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [".", "../src/graphql"]
}
Enter fullscreen mode Exit fullscreen mode

What's important is module: "CommonJS" nodejs modules are of CommonJS module type.

Let me remind you, our goal is to keep CRA intact, just add serverside to it.

And add our server app;
server/src/index.ts

import express from "express";
import path from "path";

const app = express();
app.use(express.json());

const staticPath = path.resolve(__dirname, "../build/static");
const buildPath = path.resolve(__dirname, "../build");
const indexPath = path.resolve(__dirname, "../build/index.html");

app.use("/", express.static(buildPath));
app.use("/static", express.static(staticPath));

app.all("/", (req, res) => {
  res.sendFile(indexPath);
});

app.post("/api/greetings/hello", (req, res) => {
  const name = (req.body.name || "World") as string;

  res.json({
    greeting: `Hello ${name}! From Expressjs on ${new Date().toLocaleString()}`,
  });
});

app.listen(3001, () =>
  console.log("Express server is running on localhost:3001")
);

Enter fullscreen mode Exit fullscreen mode

Lets's build client side Reactjs app using npm run build in root directory

If you check build/index.html you can see some script tags that points to some compiled artifacts under build/static. In our server/app/index.ts we created below paths to be used;

const staticPath = path.resolve(__dirname, "../build/static");
const buildPath = path.resolve(__dirname, "../build");
const indexPath = path.resolve(__dirname, "../build/index.html");

app.use("/", express.static(buildPath));
app.use("/static", express.static(staticPath));
Enter fullscreen mode Exit fullscreen mode

Also we return index.html which contains our CRA app as below;

app.all("/", (req, res) => {
  res.sendFile(indexPath);
});
Enter fullscreen mode Exit fullscreen mode

And this is how we response POST requests;

app.post("/api/greetings/hello", (req, res) => {
  const name = req.query.name || "World";
  res.json({
    greeting: `Hello ${name}! From Expressjs on ${new Date().toLocaleString()}`,
  });
});
Enter fullscreen mode Exit fullscreen mode

Finally, we need scripts part to our server package.json as below;

"scripts": {
    "server:dev": "nodemon --exec ts-node --project tsconfig.json src/index.ts",
    "server:build": "tsc --project tsconfig.json"    
  },
Enter fullscreen mode Exit fullscreen mode

Basically what server:dev does is to use ts-node to start our Expressjs written in typescript according to tsconfig.json.

For nodemon watch the changes in serverside typescript files and restart Expressjs automatically upon save, we need to add below configuration file to root directory;

nodemon.json

{
  "watch": ["."],
  "ext": "ts",
  "ignore": ["*.test.ts"],
  "delay": "3",
  "execMap": {
    "ts": "ts-node"
  }
}
Enter fullscreen mode Exit fullscreen mode

We can test our server with npm run server:dev. If we update and save index.ts, server is supposed to be restarted.

Since our CRA app is running on localhost:3000 and Expressjs on localhost:3001, sending an http request from CRA app to Expressjs normally causes CORS problem. Instead of dealing with CORS, we have an option to tell CRA app to proxy http request to Expressjs in our development environment. To do that, we need to add proxy tag to our package.json

"proxy": "http://localhost:3001",
Enter fullscreen mode Exit fullscreen mode

Adding more routes to Expressjs

We have a /api/greetins/hello route. We can add another route for goodbye. Let's do this in a separate module;

server/src/routes/Greetings.ts

import express from "express";
import { DemoVisitor } from "../../../src/graphql/types";

const router = express.Router();

router.post("/hello", (req, res) => {
  const name = (req.body.name || "World") as string;
  const id = Number(req.body.id || 0);

  const myVisitor: DemoVisitor = {
    id,
    name,
    message: `Hello ${name} :-( From Expressjs on ${new Date().toLocaleString()}`,
  };

  res.json(myVisitor);
});

router.post("/goodbye", (req, res) => {
  const name = (req.body.name || "World") as string;
  const id = Number(req.body.id || 0);

  const myVisitor: DemoVisitor = {
    id,
    name,
    message: `Goodbye ${name} :-( From Expressjs on ${new Date().toLocaleString()}`,
  };

  res.json(myVisitor);
});

export default router;

Enter fullscreen mode Exit fullscreen mode

Note that we're making use DemoVisitor model, which we already generated by GraphQL Code Generator in our client side, here on server side! Nice isn't it ?

And our index.ts become simplified;
server/src/index.ts

import express from "express";
import path from "path";
import greetings from "./routes/Greetings";

const app = express();
app.use(express.json());

const staticPath = path.resolve(__dirname, "../static");
const buildPath = path.resolve(__dirname, "..");
const indexPath = path.resolve(__dirname, "../index.html");

app.use("/", express.static(buildPath));
app.use("/static", express.static(staticPath));

app.get("/*", (req, res) => {
  res.sendFile(indexPath);
});

app.use("/api/greetings", greetings);

app.listen(3001, () =>
  console.log("Express server is running on localhost:3001")
);

Enter fullscreen mode Exit fullscreen mode

Let's check is the server still runs OK with npm run server:dev

Finally, we'll update Greetings.tsx to use its backend;

src/pages/Greetings.tsx

import {
  Button,
  createStyles,
  Grid,
  makeStyles,
  Theme,
  Typography,
} from "@material-ui/core";
import TextField from "@material-ui/core/TextField";
import { useState } from "react";
import axios from "axios";
import { Visitor } from "graphql";
import { DemoVisitor } from "../graphql/types";
import ReactJson from "react-json-view";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    grid: {
      margin: 20,
    },
    message: {
      margin: 20,
    },
  })
);

const Greetings = () => {
  const classes = useStyles({});
  const [name, setName] = useState("");
  const [helloMessage, setHelloMessage] = useState<DemoVisitor>({
    name: "",
    id: 0,
    message: "",
  });
  const [goodbyeMessage, setGoodbyeMessage] = useState<DemoVisitor>({
    name: "",
    id: 0,
    message: "",
  });

  const handleChange = (event: any) => {
    setName(event.target.value);
  };
  const handleHello = async (event: any) => {
    const { data } = await axios.post<DemoVisitor>(
      `/api/greetings/hello`,
      {
        name,
        id: 3,
      },
      {
        headers: { "Content-Type": "application/json" },
      }
    );

    setHelloMessage(data);
  };
  const handleGoodbye = async (event: any) => {
    const { data } = await axios.post<DemoVisitor>(
      `/api/greetings/goodbye`,
      {
        name,
        id: 5,
      },
      {
        headers: { "Content-Type": "application/json" },
      }
    );

    setGoodbyeMessage(data);
  };
  return (
    <Grid
      className={classes.grid}
      container
      direction="column"
      alignItems="flex-start"
      spacing={8}
    >
      <Grid item>
        <TextField
          variant="outlined"
          size="small"
          label="Name"
          onChange={handleChange}
        ></TextField>
      </Grid>
      <Grid item container direction="row" alignItems="center">
        <Button variant="contained" color="primary" onClick={handleHello}>
          Say Hello
        </Button>
        <ReactJson
          src={helloMessage}
          displayDataTypes={false}
          shouldCollapse={false}
        ></ReactJson>
      </Grid>
      <Grid item container direction="row" alignItems="center">
        <Button variant="contained" color="primary" onClick={handleGoodbye}>
          Say Goodbye
        </Button>
        <ReactJson
          src={goodbyeMessage}
          displayDataTypes={false}
          shouldCollapse={false}
        ></ReactJson>
      </Grid>
    </Grid>
  );
};

export default Greetings;
Enter fullscreen mode Exit fullscreen mode

Now we have a fully functional isomorphic app. Let's now Dockerize it.

Handling Environment variables

Our last task is to handle environment variables. A full fledged prod ready app is supposed to be controlled via its environment variables. If you bootstrap your reactjs app using a server side template, you can do it while you render the index.html. However, this is a different approach from using Create React App. Our main focus is to obey CRA structure and building our dev infrastructure this way.

Let's change the color of the app bar using an environment variable.

First, add a javascript file to hold our toolbar color environment variable with a default color red. We're simply adding REACT_APP_TOOLBAR_COLOR variable to window scope.

public/env-config.js

window.REACT_APP_TOOLBAR_COLOR='red';
Enter fullscreen mode Exit fullscreen mode

We need to update index.html to use env-config.js

public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />

    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <script src="/env-config.js"></script>

    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>

  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The only change is to add <script src="/env-config.js"></script>

Let's update our AppBar to use REACT_APP_TOOLBAR_COLOR value.

src/App.tsx

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    href: {
      margin: 20,
      color: "white",
    },
    appBar: {
      backgroundColor: window["REACT_APP_TOOLBAR_COLOR"],
    },
  })
);

const App = () => {
  const classes = useStyles({});
  return (
    <BrowserRouter>
      <AppBar position="static"  className={classes.appBar}>
Enter fullscreen mode Exit fullscreen mode

We've just added appBar style and used it.
You may receive typescript compiler error saying Element implicitly has an 'any' type because index expression is not of type 'number'. We can add "suppressImplicitAnyIndexErrors": true to tsconfig.json to suppress this error.

Let's test what we did by right click to docker-compose.yaml and select Compose up.

You must have a red app bar now!

What we actually need to do is to control this toolbar color parameter using docker-compose.yaml environment variables.
We need to add two shell script files;

generate_config_js.sh

#!/bin/sh -eu
if [ -z "${TOOLBAR_COLOR:-}" ]; then
    TOOLBAR_COLOR_JSON=undefined
else
    TOOLBAR_COLOR_JSON=$(jq -n --arg toolbar_color "$TOOLBAR_COLOR" '$toolbar_color')
fi

cat <<EOF
window.REACT_APP_TOOLBAR_COLOR=$TOOLBAR_COLOR_JSON;
EOF

Enter fullscreen mode Exit fullscreen mode

docker-entrypoint.sh

#!/bin/sh -eu
 echo "starting docker entrypoint" >&1
/app/build/generate_config_js.sh >/app/build/env-config.js
node /app/build/server
echo "express started" >&1
Enter fullscreen mode Exit fullscreen mode

First shell script is to use TOOLBAR_COLOR environment variable which we'll be supplying in docker-compose.yaml.

Second one is to update our existing env-config.js with the first shell and start node server.

Creating Docker image of our Application

If your prod environment is a Kubernetes cluster, naturally you need to create a Docker image of your app. You should also decide how to respond to the initial http request to bootstrap your Reactjs app. Although adding nginx inside our image may seem reasonable, dealing with nginx configuration adds quite a lot intricacy to the scenario. Moreover, you're still lacking a backend in which you can create some business logic!

A far easier option can be using Expressjs as backend. This way, you avoid configuration issues, in addition, you will have a backend for frontend!

We already created our Expressjs and have a running full fledged app in dev mode. We can start to create our Docker image.
First of all, let's remember, our ultimate purpose is not to make any change to CRA. It's innate build algorithm will be valid. We're just decorating our CRA with a backend.

We've already added server:build script, lets try it out with npm run server:build. It produces javascript codes from typescript;

You're supposed to have the output in a dist folder inside server folder;

server-dist

Now we need to add a Dockerfile in the root folder to craete docker image of our app;

Dockerfile

FROM node:slim as first_layer

WORKDIR /app
COPY . /app

RUN npm install && \
    npm run build

WORKDIR /app/server
RUN npm install && \
    npm run server:build

FROM node:slim as second_layer

WORKDIR /app
COPY --from=client_build /app/build /app/build
COPY --from=client_build /app/public /app/public
COPY --from=client_build /app/server/dist/server/src /app/build/server
COPY --from=client_build /app/server/node_modules /app/build/server/node_modules

COPY --from=client_build /app/docker-entrypoint.sh /app/build/docker-entrypoint.sh
COPY --from=client_build /app/generate_config_js.sh /app/build/generate_config_js.sh

RUN apt-get update && \
    apt-get install dos2unix && \
    apt-get install -y jq && \
    apt-get clean

RUN chmod +rwx /app/build/docker-entrypoint.sh && \
    chmod +rwx /app/build/generate_config_js.sh && \
    dos2unix /app/build/docker-entrypoint.sh && \
    dos2unix /app/build/generate_config_js.sh

EXPOSE 3001
ENV NODE_ENV=production

ENTRYPOINT ["/app/build/docker-entrypoint.sh"]
Enter fullscreen mode Exit fullscreen mode

.dockerignore

**/node_modules
/build
/server/dist
Enter fullscreen mode Exit fullscreen mode

We have one Dockerfile and eventually we'll have a single Docker image which includes both client and server app. However, these two apps differ in terms of handling node_modules. When we build client app, CRA produces browser downloadable .js files. After that, we don't need node_modules. So, we should get rid of it not to bloat our docker image needlessly. On the other hand, at the end of the build process of the nodejs server app, we won't have a single .js file and node_modules directory should be kept for the server to run correctly!
So, we created a two layered dockerfile. In the first one, we install both client and server packages and also build them too.
When we start the second layer, we copy only necessary artifacts from the first layer. At this point we could exclude node_modules of the CRA app.

After copying necessary files & directories, we need to install dos2unix and jq Ubuntu packages. While the former will be used to correct line endings of the shell files according linux, the latter is for json handling, in which we use in generate_config_js.sh file.

Second RUN command updates the file attributes by setting their chmod and correct the line endings.

Finally, ENTRYPOINT ["/app/build/docker-entrypoint.sh"] is our entry point.

docker-entrypoint.sh

#!/bin/sh -eu
 echo "starting docker entrypoint" >&1
/app/build/generate_config_js.sh >/app/build/env-config.js
node /app/build/server
echo "express started" >&1
Enter fullscreen mode Exit fullscreen mode

Basically, it creates env-config.js file with the output of the execution of generate_config_js.sh and starts the node server.

If you're using Docker in VS Code, definitely you would need to install

docker-vs-code

It's an awesome extension and lets you monitor and perform all docker tasks without even writing docker commands.

Assuming you've installed the docker vscode extension, you can right click Dockerfile and select Build image.... If everything goes well, docker image is built as craexpressjsdocker:latest.

Now, let's add a docker-compose.yaml file to run the docker image. Here we supply TOOLBAR_COLOR environment variable too.

version: "3.4"
services:
  client:
    image: craexpressjsdocker:latest
    ports:
      - "3001:3001"
    environment:
      TOOLBAR_COLOR: "purple"

Enter fullscreen mode Exit fullscreen mode

Let's try it out. Just right click docker-compose.yaml and select Compose up. You must have your app running on http://localhost:3001 with a purple pp bar. Let's change the toolbar color parameter in docker-compose.yaml to another color and again select Compose up. You must have your up with updated app bar color. Congratulations!

Final words

Let's recap what we have achieved;

  • We added an Expressjs server side to a bare metal CRA app without ejecting or changing its base structure. We just decorated it with a server side. So, we can update the CRA any time in the future.

  • Since we keep CRA as it is, development time is also kept unchanged. i.e., we still use webpack dev server and still have HMR. We can add any server side logic and create docker image as a whole app.

  • We've encupsulated all the complexity in Docker build phase, in Dockerfile. So, development can be done without any extra issues. This makes sense from a developer's perspective to me.

  • Since our BFF (Backend For Frontend) is not a separate api hosted with a different URL, we don't need to deal with CORS issues, neighther need we create a reverse proxy.

  • We have a ready-to-deploy docker image of our app to any Kubernetes cluster.

  • We can use environment variables in our CRA even though we did not use any server templating.

Happy coding 🌝

Discussion (1)

Collapse
tivitirist profile image
Soner ÇELİK

Nice post, Thank you.