Back to blog

Build a Full-Stack TypeScript App with NPM Workspaces

typescriptexpressreactvitemonoreponpm-workspaces
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:

📦 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.md

3. Setting Up the Monorepo

Step 1: Initialize Root Package

mkdir typescript-fullstack-demo && cd typescript-fullstack-demo
npm init -y

Configure 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 -y

packages/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 build

5. Building the Express.js Backend

Package Setup

cd packages/server
npm init -y

packages/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=development

6. Building the React Frontend with Vite

Package Setup

cd packages/client
npm create vite@latest . -- --template react-ts

Update 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 install

This installs dependencies for all workspaces.

Build Shared Package First

npm run build --workspace=@blog/shared

Development Mode

Run both frontend and backend:

npm run dev

This starts:

  • Backend on http://localhost:5000
  • Frontend on http://localhost:3000

Production Build

npm run build
npm start

8. 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 slug

Single Dependency Installation

One npm install at the root handles everything:

npm install  # Installs all workspace dependencies

Consistent 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 clean

10. Troubleshooting

Types Not Updating

If shared types don't update in server/client:

npm run build --workspace=@blog/shared

Module Not Found Errors

Reinstall from root:

rm -rf node_modules packages/*/node_modules
npm install

Port 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:


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.