Creating a simple full-stack application with monorepo using pnpm, React, Express, and Docker
A monorepo is a version-controlled code repository that holds many projects. While these projects may be related, they are often logically independent and run by different teams. Some companies host all their code in a single repository, shared among everyone. Monorepos can reach colossal sizes.
I have found this kind of software development strategy quite appealing. It removes barriers and silos between teams, making it easier to design and maintain sets of microservices that work well together.
Before we start let's have a basic understanding of what we'll be dealing with here:
- In a standard project, we depend on external dependencies used across the project.
- These dependencies will have multiple copies if multiple projects depend on them.
- If multiple packages complement each other, abstraction might not be the best approach.
- Running several apps in a single command is such a joy to show to the stakeholders.
In this article, I will build a full-stack application with pnpm workspace, React in the frontend, and ExpressJS in the backend.
pnpm workspace
Create a pnpm workspace in the root folder:
pnpm init
mkdir packages
touch pnpm-workspace.yamlLet pnpm know that our packages are part of the workspace:
# pnpm-workspace.yaml
packages:
- 'packages/*'Create a docker-compose.yml file in the root directory — we'll come back to it later.
Client
Set up a React project using Vite. Provide the name of the project as client — the name is important because that's how pnpm will detect this package.
pnpm create vite
cd client
pnpm install
pnpm run devServer
Set up an Express application inside the packages folder:
mkdir server
cd server
npm init
npm install expressExcellent — we're more than halfway through! Congratulations on building a full-stack app already (a bit of connection needed though, from client to server).
Server + Client
Create an index.js on the server to serve some data at http://localhost:3000:
const express = require("express");
const app = express();
const PORT = 3000;
app.get("/", (req, res) => {
res.json([
{ name: "John", age: 25 },
{ name: "Jane", age: 30 },
{ name: "Bob", age: 35 },
]);
});
app.listen(PORT, () => {
console.log(`I am doing great and serving at ${PORT}`);
});Add a start script in package.json:
"scripts": {
"dev": "node index.js"
}Now in the client, we use Vite's proxy to reach the backend without CORS issues. Add this to vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
strictPort: true,
host: true,
origin: "http://0.0.0.0:8080",
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ""),
},
},
},
});Nothing fancy — we run the frontend on port 8080 and proxy /api to http://localhost:3000.
Now in App.tsx, fetch the data:
import { useEffect, useState } from "react";
function App() {
const [data, setData] = useState<any>(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch("/api");
const data = await response.json();
setData(data);
};
fetchData();
}, []);
return <>{data && <pre>{JSON.stringify(data, null, 2)}</pre>}</>;
}
export default App;This is now a full-stack app! However, let's enhance the dev experience with pnpm and Docker.
Enhancements
Run both client and server with a single command from the workspace root:
"scripts": {
"client": "pnpm --filter \"client\" run dev",
"server": "pnpm --filter \"server\" run dev",
"app": "pnpm run client & pnpm run server"
}Dockerfile for client
FROM node:latest
WORKDIR /app/client
COPY package.json .
RUN npm install
COPY . .
EXPOSE 8080
CMD ["npm","run", "dev"]Dockerfile for server
FROM node:latest
WORKDIR /app/server
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm","run", "dev"]Before the docker-compose file, tweak vite.config.ts to target the Docker container name instead of localhost:
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
strictPort: true,
host: true,
origin: "http://0.0.0.0:8080",
proxy: {
"/api": {
target:
process.env.NODE_ENV === "docker"
? "http://server_c:3000"
: "http://localhost:3000",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ""),
},
},
},
});docker-compose
services:
client:
build: ./packages/client
container_name: client_c
ports:
- "8080:8080"
environment:
- NODE_ENV=docker
server:
build: ./packages/server
container_name: server_c
ports:
- "3000:3000"Run it:
docker-compose upThis covers a full-stack monorepo setup with pnpm workspaces, React, Express, and Docker. It's well maintainable, well structured, and most importantly — you can start everything with a single command.
Thank you for reading and never stop learning :)