Sharing code between frontend and backend in a Full Stack TypeScript app
In this post we’ll create a React.js frontend with create-react-app and host it on a Node.js server (I have done this earlier in this post). This time we will let this server also handle an API call which the React.js frontend will fetch. Everything will be in TypeScript.
We will validate the request and responses both on the frontend and backend. I have been using class-transformer and class-validator packages for this. This meant I had identical request and response objects on both the client and server. With npm workspaces, we can put the request and response objects in a shared package and re-use them.
Requirements:
- Node.js 16 or higher (I’m still on Node 16 because that is currently the latest Node version that works with App Engine)
- NPM, should be included in Node.js
- NPX, can be installed with
npm install -g npx
Why I would pick TypeScript over Java/Kotlin on simple back-ends (skip if you don’t care about Java)
I’ve been doing Java development over 10 years now. I like Java and Kotlin. At work I often see that people just build Spring boot REST services that only send some JSON to a frontend.
The Jackson library is a good effort to make JSON parsing possible in Java, but it’s still a hassle to create lots of Java classes with Jackson annotations. In TypeScript it’s very easy to parse JSON.
To make React frontends work with non TypeScript backends, you can write an OpenApi spec and generate Java or Kotlin code on the server side, and generate TypeScript on the client side. Frontend and backend developers can agree on the OpenAPI and both implement their part.
Both on the frontend and backend you want to validate incoming requests. On the frontend you want to validate your request to avoid sending unnecessary invalid requests to your backend, and to warn a user on incorrect input even before he has pressed submit. And on the backend you want to validate anyway because hackers might craft invalid requests. If you use Java and TypeScript, you have to write these validations twice.
If you use TypeScript on both ends, you can create a common package with classes to define your API and validation functions. Another advantage is that you can just let one TypeScript developer build both the frontend and backend parts of new functionality.
Creating the React App
We will create a full stack application and I will supply all commands needed create and run both the frontend and the backend parts.
Start by creating a root directory:
mkdir full-stack-app
cd full-stack-app
git init
Inside this folder, we are going to create a react project and a node.js server project. For react I use create-react-app. This is the official easy way to set up React, however one of the maintainers admits that it’s not your best option for serious systems on production. He mentions Next.js, Vite.js and Gatsby as more serious options.
I personally like to explore how far I can go “the easy way”. When you run into problems, you can search for a solution that solves your problem. If you jump to Next.js, Vite.js or Gatsby directly you might drown in their “solutions” for problems you don’t even have yet.
Let’s create the React App and run it using the following commands:
npx create-react-app frontend --template typescript
cd frontend
npm install class-transformer class-validator @types/testing-library__jest-dom
npm start
Test on localhost:3000 if you see a working React app. If so, adjust the App.tsx to this:
import React, {ChangeEvent, useState} from 'react';
import './App.css';
import {Length, IsString, validate} from "class-validator";
import {plainToClass} from "class-transformer";
class ResponseBody {
@Length(1,100)
@IsString()
message: string;
constructor(message: string) {
this.message = message;
}
}
class RequestBody {
@Length(1, 30)
@IsString()
name: string;
constructor(name: string) {
this.name = name;
}
}
function App() {
const responseBody: ResponseBody = {message: "No response yet"};
const [yourName, setYourName] = useState("Your name");
const [response, setResponse] = useState<ResponseBody>(responseBody);
async function handleJsonFromApi(json: any) {
let body = plainToClass(ResponseBody, json as Object);
let validationErrors = await validate(body);
if (validationErrors.length > 0) {
setResponse({message: `The server retrieved an object with ${validationErrors.length} validation errors.`})
} else {
setResponse(body);
}
}
async function fetchApi() {
const requestBody: RequestBody = new RequestBody(yourName);
const validationErrors = await validate(requestBody);
if (validationErrors.length > 0) {
setResponse({message: `The name contains ${validationErrors.length} validation errors.`})
} else {
try {
const response = await fetch('http://localhost:8080/api/', {
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
method: "POST",
body: JSON.stringify(requestBody)
});
if (response.status === 200) {
response.json().then(handleJsonFromApi);
} else {
setResponse({message: "The server denied our request."})
}
} catch (e) {
setResponse({message: "Failed fetching from the API"});
}
}
}
return (
<div className="App">
<label>Name</label><br/>
<input id="name" type="text" value={yourName} onChange={(changeEvent: ChangeEvent<HTMLInputElement>) => {
setYourName(changeEvent.target.value)
}}/>
<br/><br/>
<button onClick={() => fetchApi()}>Call API</button>
<br/><br/>
<textarea readOnly={true} style={{height: "200px"}} value={response.message}></textarea>
</div>
);
}
export default App;
You might get warned that you need to enable experimental decorators due to the decorators in class-validator:
Add the experimentalDecorators flag to the compilerOptions in your tsconfig.json file:
{
"compilerOptions": {
"experimentalDecorators": true,
......
}
.....
}
What App did we just build?
It’s a greeting app. The user enters his name, the frontend sends it to the backend and the backend will return a greeting.
Run the following command to build the react-app:
npm run build
Creating the Node.js backend
We will use Express.js on the Node.js backend. Run the following commands to create the backend:
# Move out of the frontend folder
cd ..
mkdir backend
cd backend
npm init -y
npm install express cors class-transformer class-validator
npm install @types/express @types/cors @types/node typescript ts-loader ts-node webpack webpack-cli webpack-node-externals copy-webpack-plugin -D
You need to make some changes to the package.json:
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node dist/index.js",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
......
},
"devDependencies": {
........
}
}
Create a tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true // If you disable this, you'll get an error:
// TypeError: (0 , s.default) is not a function
},
"include": ["**/*.ts"],
"exclude": ["node_modules"],
"target": "es2017",
"module": "commonjs",
"lib": ["es2017"]
}
We need to create a webpack.config.ts file:
// Name this "path2" to not clash with the path variable in the index.ts
const path2 = require("path");
const nodeExternals = require("webpack-node-externals");
const copyFiles = require('copy-webpack-plugin');
module.exports = {
entry: './index.ts',
target: "node",
mode: "production",
externals: [nodeExternals()],
resolve: {
extensions: ['.ts']
},
module: {
rules: [
// all files with a `.ts` extension will be handled by `ts-loader`
{ test: /\.ts$/, loader: 'ts-loader', exclude: /node_modules/}
]
},
plugins: [new copyFiles({ patterns: [{ from: '../frontend/build', to: 'build'}]})],
output: {
path: path2.resolve(__dirname, 'dist'),
filename: 'index.js'
},
};
The copyFiles plugin will copy the frontend code into the backend, so that our Node.js code can serve it.
Finally create the index.ts file:
import express, {NextFunction, Request, RequestHandler, Response} from "express";
import {Length, IsString} from "class-validator";
import cors from "cors";
import {plainToClass} from "class-transformer";
const path = require('path');
const app = express();
class RequestBody {
@Length(1, 30)
@IsString()
name: string;
constructor(name: string) {
this.name = name;
}
}
class ResponseBody {
@IsString()
message: string;
constructor(message: string) {
this.message = message;
}
}
// Enable cors to be able to reach the backend on localhost:8080 while running React.js in dev mode on localhost:3000
// You might want to disbale this on production.
app.use(cors());
app.use(express.json() as RequestHandler);
app.post('/api', async function(req: Request, res: Response) {
let body = plainToClass(RequestBody, req.body as Object);
let validationErrors = await validate(body);
if (validationErrors.length == 0) {
const responseBody: ResponseBody = new ResponseBody("Hello, " + body.name);
res.contentType('application/json');
res.status(200);
res.send(responseBody);
} else {
res.sendStatus(400);
}
});
// This code makes sure that any request that does not matches a static file
// in the build folder, will just serve index.html. Client side routing is
// going to make sure that the correct content will be loaded.
app.use((req: Request, res: Response, next: NextFunction) => {
if (/(.ico|.js|.css|.jpg|.png|.map)$/i.test(req.path)) {
next();
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(__dirname, 'build', 'index.html'));
}
});
app.use(express.static(path.join(__dirname, 'build')));
// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});
Summary of what the code does:
- If the url is
/api
the backend grabs the RequestBody from the request and returns a ResponseBody. - If the url is index.html or any other route that is not going to resolve to a static file, the backend loads the index.html page (the react-app). If you want a more thorough explanation about the how and why, read this post.
- If the request matches any file name in the build folder, the backend will simply serve this file. This goes for pictures, fonts, the favicon etc.
To run the backend:
- Run
npm run build
in the frontend folder. - Run
npm run build
in the backend folder. - Run
npm start
in the backend folder.
Now the @Length(1, 30) decorator should enforce that the name we enter has at least one character, and 30 characters max. We can quickly test if the validation works by not entering a name.
Run this curl command to test the backend validation directly with an empty name.
curl -H 'Content-Type: application/json' -d '{"name": ""}' -X POST http://localhost:8080/api
Getting rid of the duplicated classes
If we want to share the RequestBody and ResponseBody classes across the frontend and backend, we need a common package. We could make an npm package with these two classes, and publish it on npmjs.com, and install it using: npm install our-common-package
This would be a bit too much because:
- Maybe we want to keep our code closed source.
- It would force us to start versioning our common package.
- We only need it in two projects.
You could use npm link (explained in this article) to create symbolic link on on the common package in your node_modules that points to a local folder. However everyone cloning your repository would then need to run the npm link command on the right folder before it can compile and run the software.
Luckily, npm has somewhat copied the workspaces concept from their competitor, yarn. This works since npm 7. Npm workspaces do roughly the same as the npm link command can achieve, but the advantage is that the symbolic links are automatically set up if you run npm install in the root of the project.
Creating a “common” workspace
To use npm workspaces, we need a package.json in the root of the project:
{
"name": "full-stack-app",
"private": true,
"workspaces": ["common"]
}
Let’s create a common folder in which we put the shared code:
# Move out of the backend folder
cd ..
mkdir common
cd common
npm init -y
npm i class-validator
npm i typescript @types/node -D
Now change the highlighted things in the package.json to make sure the common module plays nice with the npm workspace. We use tsc to compile the TypeScript:
{
"private": true,
"scripts": {
"build": "tsc -b"
},
"dependencies": {
"class-validator": "^0.13.2"
},
"devDependencies": {
"@types/node": "^18.11.18",
"typescript": "^4.9.3"
}
}
Create a tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"declaration": true
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules"
],
"target": "es2017",
"module": "commonjs",
"lib": [
"es2017"
]
}
Now we can create an index.ts file with the classes:
import {IsString, Length} from "class-validator";
export class ResponseBody {
@Length(1,100)
@IsString()
message: string;
constructor(message: string) {
this.message = message;
}
}
export class RequestBody {
@Length(1, 30)
@IsString()
name: string;
constructor(name: string) {
this.name = name;
}
}
Note: the class-validator library has a lot more handy decorators for validation, so use them instead of writing custom validation code!
Finally build our new common package:
npm run build
The project structure
Go back to the root of the project and run:
npm install
Your project structure should look like this:
Updating the frontend
In the App.tsx, remove the RequestBody and ResponseBody classes and add the following import:
import React, {ChangeEvent, useState} from 'react';
import './App.css';
import {validate} from "class-validator";
import {plainToClass} from "class-transformer";
import {RequestBody, ResponseBody} from "common";
function App() {
const responseBody: ResponseBody = {message: "No response yet"};
const [yourName, setYourName] = useState("Your name");
const [response, setResponse] = useState<ResponseBody>(responseBody);
async function handleJsonFromApi(json: any) {
let body = plainToClass(ResponseBody, json as Object);
let validationErrors = await validate(body);
if (validationErrors.length > 0) {
setResponse({message: `The server retrieved an object with ${validationErrors.length} validation errors.`})
} else {
setResponse(body);
}
}
async function fetchApi() {
const requestBody: RequestBody = new RequestBody(yourName);
const validationErrors = await validate(requestBody);
if (validationErrors.length > 0) {
setResponse({message: `The name contains ${validationErrors.length} validation errors.`})
} else {
try {
const response = await fetch('http://localhost:8080/api/', {
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
method: "POST",
body: JSON.stringify(requestBody)
});
if (response.status === 200) {
response.json().then(handleJsonFromApi);
} else {
setResponse({message: "The server denied our request."})
}
} catch (e) {
setResponse({message: "Failed fetching from the API"});
}
}
}
return (
<div className="App">
<label>Name</label><br/>
<input id="name" type="text" value={yourName} onChange={(changeEvent: ChangeEvent<HTMLInputElement>) => {
setYourName(changeEvent.target.value)
}}/>
<br/><br/>
<button onClick={() => fetchApi()}>Call API</button>
<br/><br/>
<textarea readOnly={true} style={{height: "200px"}} value={response.message}></textarea>
</div>
);
}
export default App;
After this make sure you run another build:
npm run build
Updating the backend
In the index.ts of the backend, replace the RequestBody and ResponseBody classes and add the import:
import express, {NextFunction, Request, RequestHandler, Response} from "express";
import cors from "cors";
import {plainToClass} from "class-transformer";
import {RequestBody, ResponseBody} from "common";
import {validate} from "class-validator";
const path = require('path');
const app = express();
// Enable cors to be able to reach the backend on localhost:8080 while running React.js in dev mode on localhost:3000
// You might want to disbale this on production.
app.use(cors());
app.use(express.json() as RequestHandler);
app.post('/api', async function(req: Request, res: Response) {
let body = plainToClass(RequestBody, req.body as Object);
let validationErrors = await validate(body);
if (validationErrors.length == 0) {
const responseBody: ResponseBody = new ResponseBody("Hello, " + body.name);
res.contentType('application/json');
res.status(200);
res.send(responseBody);
} else {
res.sendStatus(400);
}
});
// This code makes sure that any request that does not matches a static file
// in the build folder, will just serve index.html. Client side routing is
// going to make sure that the correct content will be loaded.
app.use((req: Request, res: Response, next: NextFunction) => {
if (/(.ico|.js|.css|.jpg|.png|.map)$/i.test(req.path)) {
next();
} else {
res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.header('Expires', '-1');
res.header('Pragma', 'no-cache');
res.sendFile(path.join(__dirname, 'build', 'index.html'));
}
});
app.use(express.static(path.join(__dirname, 'build')));
// Start the server
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});
Also build the backend again, and then run it to test the code:
npm run build
npm start
The result should be identical:
How is it even possible that these imports work!?
Coming from a Java background it feels really weird that these imports are now being resolved, when the frontend and backend do not have common listed as a dependency in their package.json.
Then again, the npm link command also didn’t modify any package.json files either. By defining npm workspaces in a root package.json, you’ll automatically set symbolic links in the node_modules folder when you run npm install. On compile / runtime it doesn’t matter what’s in the package.json files, all that matters is that your dependencies are in the node_modules folder.
The full final source code can be found in this GitHub project. If you enjoyed this blog post, please follow me here on Medium, Twitter or LinkedIN.
PS: If you are going to continue building on the backend, you might want to enable the HotModuleReplacement in Webpack as I’ve shown in my post from last year.