Build a Full-Stack TypeScript App with NPM Workspaces

Building a full-stack application with TypeScript offers incredible developer experience—type safety across your entire codebase, shared interfaces between frontend and backend, and compile-time error catching. In this guide, we'll build a complete blog application using a monorepo architecture with NPM workspaces.
What You'll Learn
✅ Setting up an NPM workspaces monorepo
✅ Sharing TypeScript types between frontend and backend
✅ Building a REST API with Express.js and TypeScript
✅ Creating a React frontend with Vite
✅ Configuring TypeScript project references
✅ Development workflow for monorepos
✅ Production build and deployment setup
Prerequisites
Before starting, you should have:
- Node.js v16+ and npm v7+ (workspaces require npm 7+)
- Basic TypeScript knowledge from TypeScript Phase 1: Fundamentals
- Basic React and Express.js experience
📦 Full Source Code: The complete working example is available on GitHub: typescript-fullstack-demo
1. Why Monorepo with NPM Workspaces?
The Problem
In traditional setups, frontend and backend are separate projects with duplicated type definitions:
// backend/types/post.ts
interface Post {
id: number;
title: string;
content: string;
}
// frontend/types/post.ts (duplicated!)
interface Post {
id: number;
title: string;
content: string;
}When you update the backend API, you must manually update frontend types—error-prone and tedious.
The Solution: Monorepo
A monorepo with shared packages solves this:
// packages/shared/src/types.ts (single source of truth)
export interface Post {
id: number;
title: string;
content: string;
}
// Used in both backend AND frontend
import { Post } from '@blog/shared';Benefits:
- Single source of truth for types
- Atomic changes across packages
- Simplified dependency management
- Better code organization
2. Project Structure
Here's the complete monorepo structure we'll build:
typescript-fullstack-demo/
├── packages/
│ ├── shared/ # Shared types and utilities
│ │ ├── src/
│ │ │ ├── types.ts # Common TypeScript interfaces
│ │ │ └── index.ts # Package entry point
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── server/ # Express.js backend
│ │ ├── src/
│ │ │ ├── config/ # Database configuration
│ │ │ ├── controllers/ # Request handlers
│ │ │ ├── models/ # Data models
│ │ │ ├── routes/ # API routes
│ │ │ └── index.ts # Server entry point
│ │ ├── database/ # SQLite database files
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ └── client/ # React + Vite frontend
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── services/ # API services
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── package.json
│ └── tsconfig.json
│
├── package.json # Root workspace configuration
└── README.md3. Setting Up the Monorepo
Step 1: Initialize Root Package
mkdir typescript-fullstack-demo && cd typescript-fullstack-demo
npm init -yConfigure the root package.json for workspaces:
{
"name": "typescript-fullstack-demo",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
"server": "npm run dev --workspace=@blog/server",
"client": "npm run dev --workspace=@blog/client",
"build": "npm run build --workspaces",
"clean": "rm -rf packages/*/dist packages/*/build"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}Create the packages directory:
mkdir -p packages/{shared,server,client}4. Building the Shared Package
The shared package contains types used by both frontend and backend.
Package Configuration
cd packages/shared
npm init -ypackages/shared/package.json:
{
"name": "@blog/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}TypeScript Configuration
packages/shared/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"declarationMap": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}Define Shared Types
packages/shared/src/types.ts:
// ============ Post Types ============
export interface Post {
id?: number;
title: string;
content: string;
author: string;
excerpt?: string;
image_url?: string;
created_at?: string;
updated_at?: string;
}
export interface CreatePostRequest {
title: string;
content: string;
author: string;
excerpt?: string;
image_url?: string;
category_ids?: number[];
}
export interface UpdatePostRequest extends Partial<CreatePostRequest> {}
// ============ Comment Types ============
export interface Comment {
id?: number;
post_id: number;
author: string;
content: string;
created_at?: string;
}
export interface CreateCommentRequest {
post_id: number;
author: string;
content: string;
}
// ============ Category Types ============
export interface Category {
id?: number;
name: string;
description?: string;
}
// ============ API Response Types ============
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}packages/shared/src/index.ts:
export * from './types';Build the shared package:
npm run build5. Building the Express.js Backend
Package Setup
cd packages/server
npm init -ypackages/server/package.json:
{
"name": "@blog/server",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@blog/shared": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.6",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}TypeScript Configuration with Project References
packages/server/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@blog/shared": ["../shared/src"]
}
},
"include": ["src/**/*"],
"references": [
{ "path": "../shared" }
]
}Database Configuration
packages/server/src/config/database.ts:
import sqlite3 from 'sqlite3';
import path from 'path';
import fs from 'fs';
// Ensure database directory exists
const dbDir = path.join(__dirname, '../../database');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const dbPath = path.join(dbDir, 'blog.db');
const db = new sqlite3.Database(dbPath);
export function initializeDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
db.serialize(() => {
// Posts table
db.run(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL,
excerpt TEXT,
image_url TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Comments table
db.run(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL,
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
)
`);
// Categories table
db.run(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT
)
`);
// Post-Categories junction table
db.run(`
CREATE TABLE IF NOT EXISTS post_categories (
post_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (post_id, category_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
});
}
export default db;Post Model
packages/server/src/models/Post.ts:
import db from '../config/database';
import { Post, CreatePostRequest, UpdatePostRequest } from '@blog/shared';
export class PostModel {
static getAll(): Promise<Post[]> {
return new Promise((resolve, reject) => {
db.all('SELECT * FROM posts ORDER BY created_at DESC', (err, rows) => {
if (err) reject(err);
else resolve(rows as Post[]);
});
});
}
static getById(id: number): Promise<Post | undefined> {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM posts WHERE id = ?', [id], (err, row) => {
if (err) reject(err);
else resolve(row as Post | undefined);
});
});
}
static create(post: CreatePostRequest): Promise<Post> {
return new Promise((resolve, reject) => {
const { title, content, author, excerpt, image_url } = post;
db.run(
`INSERT INTO posts (title, content, author, excerpt, image_url)
VALUES (?, ?, ?, ?, ?)`,
[title, content, author, excerpt, image_url],
function (err) {
if (err) reject(err);
else {
PostModel.getById(this.lastID).then(resolve).catch(reject);
}
}
);
});
}
static update(id: number, post: UpdatePostRequest): Promise<Post | undefined> {
return new Promise((resolve, reject) => {
const fields: string[] = [];
const values: any[] = [];
Object.entries(post).forEach(([key, value]) => {
if (value !== undefined) {
fields.push(`${key} = ?`);
values.push(value);
}
});
if (fields.length === 0) {
return PostModel.getById(id).then(resolve).catch(reject);
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
db.run(
`UPDATE posts SET ${fields.join(', ')} WHERE id = ?`,
values,
function (err) {
if (err) reject(err);
else PostModel.getById(id).then(resolve).catch(reject);
}
);
});
}
static delete(id: number): Promise<boolean> {
return new Promise((resolve, reject) => {
db.run('DELETE FROM posts WHERE id = ?', [id], function (err) {
if (err) reject(err);
else resolve(this.changes > 0);
});
});
}
static search(query: string): Promise<Post[]> {
return new Promise((resolve, reject) => {
const searchTerm = `%${query}%`;
db.all(
`SELECT * FROM posts
WHERE title LIKE ? OR content LIKE ? OR author LIKE ?
ORDER BY created_at DESC`,
[searchTerm, searchTerm, searchTerm],
(err, rows) => {
if (err) reject(err);
else resolve(rows as Post[]);
}
);
});
}
}Post Controller
packages/server/src/controllers/postController.ts:
import { Request, Response } from 'express';
import { PostModel } from '../models/Post';
import { ApiResponse, Post, CreatePostRequest } from '@blog/shared';
export const postController = {
async getAll(req: Request, res: Response): Promise<void> {
try {
const posts = await PostModel.getAll();
const response: ApiResponse<Post[]> = {
success: true,
data: posts,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Failed to fetch posts',
};
res.status(500).json(response);
}
},
async getById(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const post = await PostModel.getById(id);
if (!post) {
const response: ApiResponse<null> = {
success: false,
error: 'Post not found',
};
res.status(404).json(response);
return;
}
const response: ApiResponse<Post> = {
success: true,
data: post,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Failed to fetch post',
};
res.status(500).json(response);
}
},
async create(req: Request, res: Response): Promise<void> {
try {
const postData: CreatePostRequest = req.body;
if (!postData.title || !postData.content || !postData.author) {
const response: ApiResponse<null> = {
success: false,
error: 'Title, content, and author are required',
};
res.status(400).json(response);
return;
}
const post = await PostModel.create(postData);
const response: ApiResponse<Post> = {
success: true,
data: post,
message: 'Post created successfully',
};
res.status(201).json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Failed to create post',
};
res.status(500).json(response);
}
},
async update(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const post = await PostModel.update(id, req.body);
if (!post) {
const response: ApiResponse<null> = {
success: false,
error: 'Post not found',
};
res.status(404).json(response);
return;
}
const response: ApiResponse<Post> = {
success: true,
data: post,
message: 'Post updated successfully',
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Failed to update post',
};
res.status(500).json(response);
}
},
async delete(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const deleted = await PostModel.delete(id);
if (!deleted) {
const response: ApiResponse<null> = {
success: false,
error: 'Post not found',
};
res.status(404).json(response);
return;
}
const response: ApiResponse<null> = {
success: true,
message: 'Post deleted successfully',
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Failed to delete post',
};
res.status(500).json(response);
}
},
async search(req: Request, res: Response): Promise<void> {
try {
const query = req.query.q as string;
if (!query) {
const response: ApiResponse<null> = {
success: false,
error: 'Search query is required',
};
res.status(400).json(response);
return;
}
const posts = await PostModel.search(query);
const response: ApiResponse<Post[]> = {
success: true,
data: posts,
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: 'Search failed',
};
res.status(500).json(response);
}
},
};Routes
packages/server/src/routes/postRoutes.ts:
import { Router } from 'express';
import { postController } from '../controllers/postController';
const router = Router();
router.get('/search', postController.search);
router.get('/', postController.getAll);
router.get('/:id', postController.getById);
router.post('/', postController.create);
router.put('/:id', postController.update);
router.delete('/:id', postController.delete);
export default router;Server Entry Point
packages/server/src/index.ts:
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { initializeDatabase } from './config/database';
import postRoutes from './routes/postRoutes';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/posts', postRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Initialize database and start server
initializeDatabase()
.then(() => {
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});
})
.catch((error) => {
console.error('Failed to initialize database:', error);
process.exit(1);
});Environment Variables
packages/server/.env:
PORT=5000
NODE_ENV=development6. Building the React Frontend with Vite
Package Setup
cd packages/client
npm create vite@latest . -- --template react-tsUpdate packages/client/package.json:
{
"name": "@blog/client",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@blog/shared": "^1.0.0",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}TypeScript Configuration
packages/client/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@blog/shared": ["../shared/src"]
}
},
"include": ["src"],
"references": [
{ "path": "../shared" }
]
}Vite Configuration
packages/client/vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@blog/shared': path.resolve(__dirname, '../shared/src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
define: {
'process.env': {},
},
});API Service with Typed Responses
packages/client/src/services/api.ts:
import axios, { AxiosInstance } from 'axios';
import {
Post,
CreatePostRequest,
UpdatePostRequest,
ApiResponse,
} from '@blog/shared';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
export const postService = {
async getAll(): Promise<Post[]> {
const response = await api.get<ApiResponse<Post[]>>('/posts');
if (!response.data.success) {
throw new Error(response.data.error);
}
return response.data.data || [];
},
async getById(id: number): Promise<Post> {
const response = await api.get<ApiResponse<Post>>(`/posts/${id}`);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.error || 'Post not found');
}
return response.data.data;
},
async create(post: CreatePostRequest): Promise<Post> {
const response = await api.post<ApiResponse<Post>>('/posts', post);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.error || 'Failed to create post');
}
return response.data.data;
},
async update(id: number, post: UpdatePostRequest): Promise<Post> {
const response = await api.put<ApiResponse<Post>>(`/posts/${id}`, post);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.error || 'Failed to update post');
}
return response.data.data;
},
async delete(id: number): Promise<void> {
const response = await api.delete<ApiResponse<null>>(`/posts/${id}`);
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to delete post');
}
},
async search(query: string): Promise<Post[]> {
const response = await api.get<ApiResponse<Post[]>>('/posts/search', {
params: { q: query },
});
if (!response.data.success) {
throw new Error(response.data.error);
}
return response.data.data || [];
},
};Post List Component
packages/client/src/components/PostList.tsx:
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Post } from '@blog/shared';
import { postService } from '../services/api';
export const PostList: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPosts = async () => {
try {
const data = await postService.getAll();
setPosts(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch posts');
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
if (loading) return <div className="loading">Loading posts...</div>;
if (error) return <div className="error">Error: {error}</div>;
return (
<div className="post-list">
<h1>Blog Posts</h1>
{posts.length === 0 ? (
<p>No posts yet. Create your first post!</p>
) : (
<div className="posts-grid">
{posts.map((post) => (
<article key={post.id} className="post-card">
{post.image_url && (
<img src={post.image_url} alt={post.title} />
)}
<div className="post-content">
<h2>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h2>
<p className="excerpt">
{post.excerpt || post.content.slice(0, 150)}...
</p>
<div className="post-meta">
<span className="author">By {post.author}</span>
<span className="date">
{new Date(post.created_at!).toLocaleDateString()}
</span>
</div>
</div>
</article>
))}
</div>
)}
</div>
);
};Post Detail with Markdown
packages/client/src/components/PostDetail.tsx:
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Post } from '@blog/shared';
import { postService } from '../services/api';
export const PostDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPost = async () => {
if (!id) return;
try {
const data = await postService.getById(parseInt(id));
setPost(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch post');
} finally {
setLoading(false);
}
};
fetchPost();
}, [id]);
const handleDelete = async () => {
if (!post?.id || !confirm('Are you sure you want to delete this post?')) {
return;
}
try {
await postService.delete(post.id);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete post');
}
};
if (loading) return <div className="loading">Loading post...</div>;
if (error) return <div className="error">Error: {error}</div>;
if (!post) return <div className="error">Post not found</div>;
return (
<article className="post-detail">
<header>
<h1>{post.title}</h1>
<div className="post-meta">
<span className="author">By {post.author}</span>
<span className="date">
{new Date(post.created_at!).toLocaleDateString()}
</span>
</div>
</header>
{post.image_url && (
<img src={post.image_url} alt={post.title} className="post-image" />
)}
<div className="post-content markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{post.content}
</ReactMarkdown>
</div>
<footer className="post-actions">
<Link to={`/posts/${post.id}/edit`} className="btn btn-edit">
Edit
</Link>
<button onClick={handleDelete} className="btn btn-delete">
Delete
</button>
<Link to="/" className="btn btn-back">
Back to Posts
</Link>
</footer>
</article>
);
};App Component with Routing
packages/client/src/App.tsx:
import React from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { PostList } from './components/PostList';
import { PostDetail } from './components/PostDetail';
import { CreatePost } from './components/CreatePost';
import { EditPost } from './components/EditPost';
import './styles/App.css';
const App: React.FC = () => {
return (
<BrowserRouter>
<div className="app">
<header className="app-header">
<nav>
<Link to="/" className="logo">
TypeScript Blog
</Link>
<Link to="/posts/new" className="btn btn-primary">
New Post
</Link>
</nav>
</header>
<main className="app-main">
<Routes>
<Route path="/" element={<PostList />} />
<Route path="/posts/new" element={<CreatePost />} />
<Route path="/posts/:id" element={<PostDetail />} />
<Route path="/posts/:id/edit" element={<EditPost />} />
</Routes>
</main>
<footer className="app-footer">
<p>Built with TypeScript, Express, React & Vite</p>
</footer>
</div>
</BrowserRouter>
);
};
export default App;7. Running the Application
Install All Dependencies
From the root directory:
npm installThis installs dependencies for all workspaces.
Build Shared Package First
npm run build --workspace=@blog/sharedDevelopment Mode
Run both frontend and backend:
npm run devThis starts:
- Backend on
http://localhost:5000 - Frontend on
http://localhost:3000
Production Build
npm run build
npm start8. Key Benefits of This Architecture
Type Safety Across the Stack
When you change a type in @blog/shared, TypeScript immediately catches errors in both frontend and backend:
// If you add a required field to Post
export interface Post {
id?: number;
title: string;
content: string;
author: string;
slug: string; // New required field
}
// TypeScript errors in:
// - Backend: PostModel.create() missing slug
// - Frontend: CreatePost form missing slug input
// - API service: Responses missing slugSingle Dependency Installation
One npm install at the root handles everything:
npm install # Installs all workspace dependenciesConsistent Tooling
Share configurations across packages:
- ESLint rules
- Prettier settings
- TypeScript settings
- Build scripts
9. Workspace Commands Reference
# Install dependency in specific workspace
npm install axios --workspace=@blog/client
# Run script in specific workspace
npm run dev --workspace=@blog/server
# Run script in all workspaces
npm run build --workspaces
# List all workspaces
npm ls --workspaces
# Clean all build artifacts
npm run clean10. Troubleshooting
Types Not Updating
If shared types don't update in server/client:
npm run build --workspace=@blog/sharedModule Not Found Errors
Reinstall from root:
rm -rf node_modules packages/*/node_modules
npm installPort Conflicts
- Backend: Edit
packages/server/.env - Frontend: Edit
packages/client/vite.config.ts
Summary
In this guide, you learned:
✅ Setting up an NPM workspaces monorepo structure
✅ Creating shared TypeScript packages for type safety
✅ Building a REST API with Express.js and TypeScript
✅ Creating a React frontend with Vite
✅ Configuring TypeScript project references
✅ Development and production workflows
✅ Troubleshooting common monorepo issues
This architecture scales well—you can add more shared packages (@blog/utils, @blog/validation) and the type safety extends automatically.
Next Steps
Now that you have a full-stack TypeScript monorepo, explore:
Extend Your Application:
- Add user authentication with JWT
- Implement pagination and filtering
- Add image upload functionality
- Deploy with Docker
Learn More:
- TypeScript Phase 2: Frontend Development - Advanced React patterns
- JavaScript Build Tools & Bundlers Explained - Understand how Vite works under the hood
- NPM Workspaces: Monorepo vs Polyrepo - Deep dive into monorepo architecture
- Express.js vs Fastify - Compare Node.js frameworks
Part of the TypeScript Learning Roadmap series
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.