Master Full-Stack Monorepos: A Step-by-Step Guide

RMAG news

Ever wondered how to streamline your full-stack development process? This guide walks you through setting up a full-stack monorepo with the latest tools and technologies. Follow these step-by-step instructions to create a modern, robust, and scalable application using:

> TypeScript
> Vite
> pnpm
> Docker
> And more!

Full-Stack Monorepo Setup Documentation

This documentation covers the setup and usage of a full-stack monorepo project with the following technologies and tools:

Here are the tools grouped into relevant categories:

Programming Language and Frameworks

– TypeScript
– Express
– React

Build and Development Tools

– Vite
– pnpm
– Docker
– ESLint
– Prettier
– Husky

Database and ORM

– Sequelize
– PostgreSQL

Authentication and Security

– jsonwebtoken
– bcryptjs
– cookie-parser
– AWS WAF

AWS and Cloud Services

– AWS SDK v3
– Terraform

Logging and Validation

– Winston
– Zod

Networking and API

– Axios

UI Frameworks and Libraries

– Bootstrap

CI/CD and DevOps

– GitHub Actions
– SonarQube
– Trivy

These groupings can help organize your tools by their purpose, making it easier for your readers to understand the different aspects of your full-stack monorepo setup.

Project Structure

my-monorepo/

├── packages/
│ ├── server/
│ │ ├── Dockerfile
│ │ ├── src/
│ │ │ ├── config/
│ │ │ │ └── sequelize.ts
│ │ │ ├── controllers/
│ │ │ │ └── userController.ts
│ │ │ ├── middleware/
│ │ │ │ └── auth.ts
│ │ │ ├── models/
│ │ │ │ └── user.ts
│ │ │ ├── repositories/
│ │ │ │ └── userRepository.ts
│ │ │ ├── routes/
│ │ │ │ ├── auth.ts
│ │ │ │ └── user.ts
│ │ │ ├── services/
│ │ │ │ └── userService.ts
│ │ │ ├── utils/
│ │ │ │ ├── logger.ts
│ │ │ │ └── syncEnv.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── .env.development
│ │ ├── .env.beta
│ │ ├── .env.test
│ │ ├── .env.production
│ │ ├── sequelize-cli/
│ │ │ ├── config/
│ │ │ │ └── config.js
│ │ │ ├── models/
│ │ │ ├── migrations/
│ │ │ └── seeders/
│ ├── client/
│ │ ├── src/
│ │ │ ├── axios.ts
│ │ │ ├── context/
│ │ │ │ └── AuthContext.tsx
│ │ │ ├── components/
│ │ │ │ └── AuthExample.tsx
│ │ │ ├── App.tsx
│ │ │ ├── main.tsx
│ │ │ └── index.html
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── shared/
│ │ ├── src/
│ │ │ └── utils.ts
│ │ ├── package.json
│ │ └── tsconfig.json
├── terraform/
│ ├── development/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── production/
│ │ └── main.tf
├── .github/
│ └── workflows/
│ └── deploy.yml
├── .eslintrc.json
├── .prettierrc
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json

Step-by-Step Setup

1. Initialize the Monorepo

Create Project Structure:

mkdir my-monorepo
cd my-monorepo
mkdir packages
mkdir packages/server packages/client packages/shared
touch pnpm-workspace.yaml
touch .env.development .env.beta .env.test .env.production
touch docker-compose.yml
touch .eslintrc.json .prettierrc

Initialize the Monorepo with pnpm:

pnpm init -y

Set Up Workspaces in pnpm-workspace.yaml:

packages:
packages/*’

2. Environment Variables

Create Environment Files:

.env.development:

VITE_NODE_ENV=development
VITE_DATABASE_URL=postgres://user:password@db:5432/mydb_dev
VITE_PORT=4000
VITE_JWT_SECRET=your_jwt_secret

.env.beta, .env.test, .env.production: similarly create these files with appropriate values.

3. Docker Configuration

Create docker-compose.yml:

version: 3.8′
services:
server:
build: ./packages/server
ports:
${VITE_PORT}:${VITE_PORT}”
env_file:
./.env.development
depends_on:
db
db:
image: postgres:latest
ports:
5432:5432″
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb_development

Create Dockerfile for Server in packages/server/Dockerfile:

FROM node:22

WORKDIR /app

COPY package*.json ./
COPY pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install

COPY . .

RUN pnpm -r build

CMD [“pnpm”, “dev”]

4. TypeScript Configuration

Create the Root tsconfig.json:

{
“compilerOptions”: {
“baseUrl”: “.”,
“paths”: {
“@shared/*”: [“packages/shared/src/*”],
“@client/*”: [“packages/client/src/*”],
“@server/*”: [“packages/server/src/*”]
}
},
“include”: [“packages/*/src”]
}

Install TypeScript:

pnpm add -D typescript

5. Linting and Formatting

Create .eslintrc.json:

{
“extends”: [“eslint:recommended”, “plugin:@typescript-eslint/recommended”, “prettier”],
“plugins”: [“@typescript-eslint”],
“parser”: “@typescript-eslint/parser”,
“env”: {
“browser”: true,
“es2021”: true,
“node”: true
},
“rules”: {}
}

Create .prettierrc:

{
“semi”: true,
“singleQuote”: true,
“printWidth”: 80,
“tabWidth”: 2,
“trailingComma”: “all”
}

Install ESLint and Prettier:

pnpm add -D eslint prettier eslint-plugin-prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser

6. Husky

Install Husky:

pnpm add -D husky
pnpm dlx husky-init && pnpm install

Add a Pre-commit Hook:

npx husky add .husky/pre-commit “pnpm lint”

7. Configure Server with Controller-Service-Repository Pattern, Logging, and Authentication

Install Dependencies:

pnpm add express sequelize pg pg-hstore jsonwebtoken bcryptjs cookie-parser zod winston @aws-sdk/client-secrets-manager
pnpm add -D @types/express @types/node @types/jsonwebtoken @types/bcryptjs @types/cookie-parser @types/sequelize

Configure vite.config.ts:

packages/server/vite.config.ts:

import { defineConfig } from vite;

export default defineConfig({
server: {
port: Number(import.meta.env.VITE_PORT) || 4000,
}
});

Create Sequelize Configuration:

packages/server/src/config/sequelize.ts:

import { Sequelize } from sequelize;

const sequelize = new Sequelize(import.meta.env.VITE_DATABASE_URL as string, {
dialect: postgres,
});

export default sequelize;

Create User Model:

packages/server/src/models/user.ts:

import { DataTypes, Model } from sequelize;
import sequelize from ../config/sequelize;

class User extends Model {
public id!: number;
public email!: string;
public password!: string;
}

User.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
sequelize,
tableName: users,
}
);

export default User;

Create Repository:

packages/server/src/repositories/userRepository.ts:

import User from ../models/user;

class UserRepository {
async create(email: string, password: string) {
return User.create({ email, password });
}

async findByEmail(email: string) {
return User.findOne({ where: { email } });
}

async findById(id: number) {
return User.findByPk(id);
}
}

export default new UserRepository();

Create Service:

packages/server/src/services/userService.ts:

import bcrypt from bcryptjs;
import userRepository from ../repositories/userRepository;
import { generateToken } from ../utils/auth;
import User from ../models/user;

class UserService {
async register(email: string, password: string) {
const hashedPassword = await bcrypt.hash(password, 10);
const user = await userRepository.create(email, hashedPassword);
const token = generateToken(user);
return { user, token };
}

async login(email: string, password: string) {
const user = await userRepository.findByEmail(email);
if (!user) throw new Error(Invalid email or password);
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) throw new Error(Invalid email or password);
const token = generateToken(user);
return { user, token };
}

async getUserById(id: number) {
return userRepository.findById(id);
}
}

export default new UserService();

Create Controller:

packages/server/src/controllers/userController.ts:

import { Request, Response } from express;
import userService from ../services/userService;
import logger from ../utils/logger;

class UserController {
async register(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await userService.register(email, password);
res.cookie(token, token, { httpOnly: true });
res.status(201).json({ message: User registered, user });
} catch (error) {
logger.error(Error in register: %o, error);
res.status(400).json({ message: error.message });
}
}

async login(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await userService.login(email, password);
res.cookie(token, token, { httpOnly: true });
res.status(200).json({ message: User logged in, user });
} catch (error) {
logger.error(Error in login: %o, error);
res.status(400).json({ message: error.message });
}
}

async logout(req: Request, res: Response) {
res.clearCookie(token);
res.status(200).json({ message: User logged out });
}

async profile(req: Request, res: Response) {
try {
const user = await userService.getUserById(req.user.id);
res.json({ user });
} catch (error) {
logger.error(Error in profile: %o, error);
res.status(400).json({ message: error.message });
}
}
}

export default new UserController();

Authentication Middleware:

packages/server/src/middleware/auth.ts:

import { Request, Response, NextFunction } from express;
import jwt from jsonwebtoken;
import User from ../models/user;

export const authenticateJWT = (req: Request, res: Response, next: NextFunction) => {
const token = req.cookies.token;

if (token) {
jwt.verify(token, import.meta.env.VITE_JWT_SECRET as string, (err, user) => {
if (err) {
return res.sendStatus(403);
}
req.user = user;
next();
});
} else {
res.sendStatus(401);
}
};

export const generateToken = (user: User) => {
return jwt.sign({ id: user.id, email: user.email }, import.meta.env.VITE_JWT_SECRET as string, { expiresIn: 1h });
};

Logging with Winston:

packages/server/src/utils/logger.ts:

import { createLogger, format, transports } from winston;

const logger = createLogger({
level: info,
format: format.combine(
format.timestamp({ format: YYYY-MM-DD HH:mm:ss }),
format.errors({ stack: true }),
format.splat(),
format.json()
),
defaultMeta: { service: user-service },
transports: [
new transports.File({ filename: error.log, level: error }),
new transports.File({ filename: combined.log }),
],
});

if (process.env.NODE_ENV !== production) {
logger.add(new transports.Console({
format: format.combine(
format.colorize(),
format.simple()
)
}));
}

export default logger;

Create Routes:

packages/server/src/routes/auth.ts:

import express from express;
import userController from ../controllers/userController;

const router = express.Router();

router.post(/register, userController.register);
router.post(/login, userController.login);
router.post(/logout, userController.logout);

export default router;

packages/server/src/routes/user.ts:

import express from express;
import { authenticateJWT } from ../middleware/auth;
import userController from ../controllers/userController;

const router = express.Router();

router.get(/profile, authenticateJWT, userController.profile);

export default router;

Sync Environment Variables from AWS Secrets Manager:

packages/server/src/utils/syncEnv.ts:

import { SecretsManagerClient, GetSecretValueCommand } from @aws-sdk/client-secrets-manager;
import dotenv from dotenv;
dotenv.config();

const client = new SecretsManagerClient({ region: your-region });

async function syncEnv() {
const secretName = your-secret-name;
const command = new GetSecretValueCommand({ SecretId: secretName });

try {
const data = await client.send(command);
if (data.SecretString) {
const secrets = JSON.parse(data.SecretString);
for (const key in secrets) {
import.meta.env[key] = secrets[key];
}
}
} catch (err) {
console.error(err);
}
}

syncEnv();

Main Server File:

packages/server/src/index.ts:

import express from express;
import cookieParser from cookie-parser;
import dotenv from dotenv;
import authRoutes from ./routes/auth;
import userRoutes from ./routes/user;
import sequelize from ./config/sequelize;
import ./utils/syncEnv;
import logger from ./utils/logger;

dotenv.config();

const app = express();
const port = import.meta.env.VITE_PORT || 4000;

app.use(express.json());
app.use(cookieParser());

app.use(/auth, authRoutes);
app.use(/user, userRoutes);

app.get(/, (req, res) => {
res.send(Hello from Express and TypeScript!);
});

sequelize.sync().then(() => {
app.listen(port, () => {
logger.info(`Server is running at http://localhost:${port}`);
});
});

8. Frontend Configuration

Create package.json for Client:

{
“name”: “client”,
“version”: “1.0.0”,
“scripts”: {
“dev”: “vite”,
“build”: “vite build”,
“test”: “vitest”,
“e2e”: “playwright test”
},
“dependencies”: {
“react”: “^17.0.2”,
“react-dom”: “^17.0.2”,
“bootstrap”: “^5.0.2”,
“axios”: “^0.21.1”
},
“devDependencies”: {
“vite”: “^2.3.8”,
“vitest”: “^0.0.134”,
“typescript”: “^4.2.4”,
“@vitejs/plugin-react”: “^1.1.0”,
“@playwright/test”: “^1.12.3”
}
}

Create tsconfig.json in Client:

{
“extends”: “../../tsconfig.json”,
“compilerOptions”: {
“outDir”: “dist”,
“rootDir”: “src”,
“module”: “esnext”,
“target”: “es6”,
“strict”: true,
“esModuleInterop”: true
},
“include”: [“src”]
}

Create vite.config.ts for Client:

packages/client/vite.config.ts:

import { defineConfig } from vite;
import react from @vitejs/plugin-react;

export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
});

Set Up Axios:

packages/client/src/axios.ts:

import axios from axios;

const api = axios.create({

baseURL: import.meta.env.VITE_API_URL || http://localhost:4000,
withCredentials: true,
});

export default api;

Auth Context:

packages/client/src/context/AuthContext.tsx:

import React, { createContext, useState, useEffect, ReactNode } from react;
import api from ../axios;

interface AuthContextProps {
user: any;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextProps | undefined>(undefined);

export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<any>(null);

useEffect(() => {
// Check if the user is authenticated on mount
const fetchUser = async () => {
try {
const response = await api.get(/user/profile);
setUser(response.data.user);
} catch (err) {
setUser(null);
}
};

fetchUser();
}, []);

const login = async (email: string, password: string) => {
const response = await api.post(/auth/login, { email, password });
setUser(response.data.user);
};

const logout = async () => {
await api.post(/auth/logout);
setUser(null);
};

return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};

export const useAuth = () => {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error(useAuth must be used within an AuthProvider);
}
return context;
};

Auth Component Example:

packages/client/src/components/AuthExample.tsx:

import React, { useState } from react;
import { useAuth } from ../context/AuthContext;

const AuthExample: React.FC = () => {
const { user, login, logout } = useAuth();
const [email, setEmail] = useState();
const [password, setPassword] = useState();

const handleLogin = async () => {
await login(email, password);
};

return (
<div>
{user ? (
<div>
<h1>Welcome, {user.email}</h1>
<button onClick={logout}>Logout</button>
</div>
) : (
<div>
<h1>Login</h1>
<input
type=email
placeholder=Email
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type=password
placeholder=Password
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleLogin}>Login</button>
</div>
)}
</div>
);
};

export default AuthExample;

Main Component:

packages/client/src/App.tsx:

import React from react;
import { AuthProvider } from ./context/AuthContext;
import AuthExample from ./components/AuthExample;

const App: React.FC = () => {
return (
<AuthProvider>
<AuthExample />
</AuthProvider>
);
};

export default App;

Index File:

packages/client/src/main.tsx:

import React from react;
import ReactDOM from react-dom;
import App from ./App;
import bootstrap/dist/css/bootstrap.min.css;

ReactDOM.render(<App />, document.getElementById(root));

HTML File:

packages/client/src/index.html:

<!DOCTYPE html>
<html lang=“en”>
<head>
<meta charset=“UTF-8”>
<meta name=“viewport” content=“width=device-width, initial-scale=1.0”>
<title>React Vite App</title>
</head>
<body>
<div id=“root”></div>
<script type=“module” src=“/src/main.tsx”></script>
</body>
</html>

Step 9: CI/CD Configuration with GitHub Actions

Create .github/workflows/deploy.yml:

name: CI/CD Pipeline

on:
push:
branches:
main
development
beta
test
production
pull_request:
branches:
main

jobs:
build:
name: Build and Test
runs-on: ubuntu-latest

steps:
name: Checkout code
uses: actions/checkout@v2

name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 22′
cache: pnpm’

name: Install dependencies
run: pnpm install

name: Run tests
run: pnpm test

name: Run linting
run: pnpm lint

name: Run formatting
run: pnpm format

sonar:
name: SonarQube Scan
runs-on: ubuntu-latest
needs: build

steps:
name: Checkout code
uses: actions/checkout@v2

name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 22′
cache: pnpm’

name: Install dependencies
run: pnpm install

name: Run SonarQube scan
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
pnpm install -g sonar-scanner
sonar-scanner
-Dsonar.projectKey=my-project
-Dsonar.sources=.
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
-Dsonar.login=${{ secrets.SONAR_TOKEN }}

trivy:
name: Trivy Scan
runs-on: ubuntu-latest
needs: build

steps:
name: Checkout code
uses: actions/checkout@v2

name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1

name: Build Docker image
run: docker build -t my-app .

name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app

deploy:
name: Deploy
needs: [build, sonar, trivy]
runs-on: ubuntu-latest
if: github.ref == ‘refs/heads/main’ || github.ref == ‘refs/heads/development’ || github.ref == ‘refs/heads/beta’ || github.ref == ‘refs/heads/test’ || github.ref == ‘refs/heads/production’

steps:
name: Checkout code
uses: actions/checkout@v2

name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 22′
cache: pnpm’

name: Install dependencies
run: pnpm install

name: Build project
run: pnpm run build

name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2

name: Deploy frontend to S3 and CloudFront
run: |
aws s3 sync packages/client/dist s3://${{ secrets.S3_BUCKET_NAME }}
aws cloudfront create-invalidation –distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} –paths “/*”

name: Deploy backend with Terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
cd terraform/${{ github.ref_name }}
terraform init
terraform apply -auto-approve

release:
name: Create Release
runs-on: ubuntu-latest
needs: deploy
if: github.ref == ‘refs/heads/main’

steps:
name: Checkout code
uses: actions/checkout@v2

name: Create release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v1.0.${{ github.run_number }}
release_name: Release v1.0.${{ github.run_number }}
draft: false
prerelease: false

Step 10: Configure Secrets in GitHub

In your GitHub repository, navigate to Settings -> Secrets and add the following secrets:

AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
S3_BUCKET_NAME
CLOUDFRONT_DISTRIBUTION_ID
SONAR_HOST_URL
SONAR_TOKEN

GITHUB_TOKEN (usually auto-generated by GitHub Actions)

Step 11: Install Dependencies and Run

Install all dependencies:

pnpm install

Run local development:

pnpm run dev

Push to GitHub:

git add .
git commit -m “Initial setup with CI/CD, Docker, and Terraform”
git push origin main

Step 12: Sequelize Commands

Generate a new migration:

npx sequelize-cli migration:generate –name migration_name

Run migrations:

npx sequelize-cli db:migrate

Create a new model:

npx sequelize-cli model:generate –name ModelName –attributes name:string,age:integer

Generate seed data:

npx sequelize-cli seed:generate –name seed_name

Run seeders:

npx sequelize-cli db:seed:all
Please follow and like us:
Pin Share