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;
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;
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;
Now our Reactjs app is ready. Although it lacks greetings funtionalities yet, you can still navigate between pages.
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
client-schema.graphql
type DemoVisitor {
name: String!
id: Int!
message: String
}
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"]
}
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")
);
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));
Also we return index.html which contains our CRA app as below;
app.all("/", (req, res) => {
res.sendFile(indexPath);
});
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()}`,
});
});
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"
},
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"
}
}
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",
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;
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")
);
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;
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';
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>
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}>
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
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
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;
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"]
.dockerignore
**/node_modules
/build
/server/dist
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
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
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"
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 🌝
Top comments (1)
Nice post, Thank you.