Skip to main content

Command Palette

Search for a command to run...

Building a Full-Stack TypeScript Monorepo with React and Hono

Updated
17 min read
Building a Full-Stack TypeScript Monorepo with React and Hono

This article guides you through creating a full-stack TypeScript monorepo from scratch. By the end, you'll have a React frontend and Hono API backend sharing the same repository with unified tooling for linting, formatting, and commit conventions.

Prerequisites

Before starting, ensure you have installed:

  • Node.js 20 or higher

  • npm 10 or higher

  • Git

What is a Monorepo?

A monorepo (monolithic repository) is a software development strategy where multiple projects are stored in a single repository. Instead of having separate repositories for your frontend, backend, and shared libraries, everything lives together under one roof.

Monorepo vs. Polyrepo

To understand monorepos, let's compare them with the traditional polyrepo approach:

Polyrepo (Multiple Repositories):

github.com/your-org/frontend    → React application
github.com/your-org/backend     → Hono API
github.com/your-org/shared-ui   → Component library
github.com/your-org/utils       → Shared utilities

Monorepo (Single Repository):

github.com/your-org/platform
├── apps/
│   ├── frontend/    → React application
│   └── backend/     → Hono API
└── packages/
    ├── shared-ui/   → Component library
    └── utils/       → Shared utilities

How Monorepos Work

In a JavaScript/TypeScript monorepo, package managers like npm, Yarn, or pnpm provide workspaces functionality. Workspaces allow you to:

  1. Link packages locally: Instead of publishing @your-org/utils to npm and installing it in your frontend, the package manager creates symlinks between workspace packages. Changes are immediately available without publishing.

  2. Hoist shared dependencies: Common dependencies (like TypeScript or React) are installed once at the root level and shared across all packages, reducing disk space and ensuring version consistency.

  3. Run scripts across packages: Execute commands like npm run build across all packages or target specific workspaces with flags like -w @your-org/frontend.

Monorepo Tools

While npm workspaces (which we'll use in this guide) provide basic monorepo functionality, specialized tools offer additional features:

ToolDescription
npm/yarn/pnpm workspacesBuilt-in workspace support in package managers
TurborepoA high-performance build system focused on speed

For this guide, we'll use npm workspaces as it requires no additional dependencies and covers the essential functionality needed for most projects.

Why a Monorepo?

Before we begin, let's understand the benefits of a monorepo architecture:

  • Shared tooling: Configure ESLint, Prettier, and TypeScript once for all packages

  • Atomic commits: Changes spanning the frontend and backend can be committed together

  • Simplified dependency management: Shared dependencies are hoisted to the root

  • Cross-package imports: Frontend can import types directly from backend

  • Unified CI/CD: A single pipeline handles all packages

Step 1: Initialize the Project

Start by creating your project directory and initializing git:

mkdir node-monorepo
cd node-monorepo
git init

Create the folder structure for our monorepo:

mkdir -p apps/backend/src
mkdir -p apps/frontend/src
mkdir packages

We use the apps/packages convention, which is widely adopted in the JavaScript ecosystem:

  • apps/ contains deployable applications (our backend and frontend)

  • packages/ is reserved for shared libraries (we'll leave it empty for now)

Step 2: Create the Root Package Configuration

2.1 Initialize package.json

Initialize the root package using npm:

npm init -y

Now open package.json and replace its contents with:

{
  "name": "node-monorepo",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\"",
    "dev:backend": "npm run dev -w @node-monorepo/backend",
    "dev:frontend": "npm run dev -w @node-monorepo/frontend",
    "build:backend": "npm run build -w @node-monorepo/backend",
    "build:frontend": "npm run build -w @node-monorepo/frontend",
    "build": "npm run build:backend && npm run build:frontend",
    "start:backend": "npm run start -w @node-monorepo/backend",
    "preview:frontend": "npm run preview -w @node-monorepo/frontend",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "lint:format": "npm run lint:fix && npm run format",
    "prepare": "husky || true",
    "commit": "commit"
  },
  "devDependencies": {
    "@commitlint/cli": "^20.3.1",
    "@commitlint/config-conventional": "^20.3.1",
    "@commitlint/prompt-cli": "^20.3.1",
    "@eslint/js": "^9.18.0",
    "concurrently": "^9.1.2",
    "eslint": "^9.18.0",
    "eslint-config-prettier": "^10.0.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.18",
    "globals": "^17.0.0",
    "husky": "^9.1.7",
    "prettier": "^3.4.2",
    "typescript": "^5.7.3",
    "typescript-eslint": "^8.21.0"
  }
}

Let's understand the key properties:

PropertyPurpose
private: truePrevents accidental publishing to npm
type: "module"Enables ES modules throughout the project
workspacesDefines npm workspaces—npm will link packages and hoist shared dependencies

About the scripts:

  • dev: Runs both servers in parallel using concurrently. We can't use npm run dev --workspaces because that runs scripts sequentially, not in parallel.

  • -w @node-monorepo/backend: The -w flag targets a specific workspace by name.

  • prepare: Runs automatically after npm install to set up Husky. The || true prevents failures in CI environments where git might not be initialized.

About devDependencies:

All shared tooling (ESLint, Prettier, TypeScript, Husky, Commitlint) lives at the root. This ensures:

  • Consistent versions across all packages

  • Single source of truth for configuration

  • Reduced duplication in node_modules

2.2 Create tsconfig.base.json

In a monorepo, TypeScript configurations often share many options. Instead of duplicating them in every workspace, we create a base configuration that all others extend from.

Create tsconfig.base.json in the project root:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "skipLibCheck": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true,
    "allowUnreachableCode": false,
    "noErrorTruncation": true,
    "noPropertyAccessFromIndexSignature": true,
    "resolveJsonModule": true,
    "esModuleInterop": true
  }
}

Key options explained:

OptionPurpose
strictEnables all strict type-checking options
verbatimModuleSyntaxEnforces explicit type imports for better tree-shaking
noUnusedLocals / noUnusedParametersCatches unused variables and parameters
noFallthroughCasesInSwitchPrevents accidental fallthrough in switch statements
noPropertyAccessFromIndexSignatureForces bracket notation for index signatures, making dynamic access explicit
forceConsistentCasingInFileNamesPrevents issues on case-sensitive file systems

2.3 Create Root tsconfig.json

Now, create tsconfig.json that extends the base configuration. This config is specifically for the root-level config files (ESLint, Prettier, Commitlint):

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2023"],
    "types": ["node"],
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "moduleDetection": "force",
    "noEmit": true
  },
  "include": ["eslint.config.ts", "commitlint.config.ts", "prettier.config.ts"]
}

By using "extends": "./tsconfig.base.json", we inherit all the strict options from the base config and only specify what's unique to this context:

  • lib: ["ES2023"]: Standard library types (no DOM since these run in Node.js)

  • types: ["node"]: Node.js type definitions

  • noEmit: true: We're only type-checking, not compiling

  • strict: true: Enables all strict type-checking options

Step 3: Create the Backend (Hono API)

3.1 Initialize Backend package.json

Navigate to the backend directory and initialize the package:

cd apps/backend
npm init -y

Open apps/backend/package.json and replace its contents with:

{
  "name": "@node-monorepo/backend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@hono/node-server": "^1.13.8",
    "hono": "^4.6.18"
  },
  "devDependencies": {
    "@types/node": "^25.0.9",
    "tsx": "^4.19.4"
  }
}

Key decisions:

  • Scoped name @node-monorepo/backend: Follows npm's scoped package convention. This prevents naming conflicts and makes workspace references clearer.

  • tsx for development: A TypeScript execution engine that provides fast compilation via esbuild and includes watch mode for automatic reloading.

  • Hono + @hono/node-server: Hono is a lightweight, fast web framework. The @hono/node-server adapter allows it to run on Node.js.

3.2 Create Backend tsconfig.json

Create apps/backend/tsconfig.json that extends the base configuration:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "sourceMap": true,
    "types": ["node"],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",
    "removeComments": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "#/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Notice how much shorter this is compared to a standalone config. By extending ../../tsconfig.base.json, we inherit all the strict options and only specify what's unique to the backend.

Important options explained:

OptionValuePurpose
module"NodeNext"Proper Node.js ES module support
moduleResolution"NodeNext"Matches the module system for correct resolution
jsx"react-jsx"Enables JSX support (Hono has its own JSX runtime)
jsxImportSource"hono/jsx"Uses Hono's JSX runtime instead of React
sourceMaptrueEnables debugging in VS Code
paths{"#/*": ["./src/*"]}Path alias for clean imports

Why #/ for the path alias? We use #/ for backend and @/ for frontend to create a clear visual distinction. In the backend code, you can write:

import { someUtil } from '#/utils/helper';
// Instead of: import { someUtil } from '../../../utils/helper';

3.3 Create the Backend API

Create the main entry point at apps/backend/src/index.ts:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';

const app = new Hono();

app.use(
  '/api/*',
  cors({
    origin: 'http://localhost:5173',
  })
);

app.get('/api/hello', c => {
  return c.json({ message: 'Hello World from Hono!' });
});

const port = 3000;
console.log(`Server is running on http://localhost:${port}`);

serve({
  fetch: app.fetch,
  port,
});

Let's break down this code:

  1. CORS middleware: The frontend runs on port 5173 (Vite's default), so we explicitly allow that origin. Without this, the browser would block requests from the frontend to the backend due to the same-origin policy.

  2. /api/* prefix: Prefixing all API routes with /api is a common convention that:

    • Makes it easy to distinguish API calls from static assets

    • Simplifies reverse proxy configuration in production

    • Allows CORS to be applied only to API routes

  3. /api/hello endpoint: A simple GET endpoint that returns a JSON message. The c parameter is Hono's context object, which provides request/response utilities.

  4. serve() function: The @hono/node-server adapter that starts the HTTP server.

Step 4: Create the Frontend (React + Vite)

4.1 Initialize Frontend package.json

Navigate to the frontend directory and initialize the package:

cd apps/frontend
npm init -y

Open apps/frontend/package.json and replace its contents with:

{
  "name": "@node-monorepo/frontend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.7",
    "@types/react-dom": "^19.0.3",
    "@vitejs/plugin-react": "^5.1.2",
    "vite": "^7.3.1"
  }
}

Why separate dependencies from the root? Runtime dependencies (react, react-dom) are placed in each workspace because:

  • They are specific to that application

  • They will be bundled into the final build

  • Different apps might need different versions

4.2 Create Frontend TypeScript Configuration

The frontend uses a multi-file TypeScript configuration pattern recommended by Vite. This allows separate settings for browser code and Node.js code (like vite.config.ts).

Create apps/frontend/tsconfig.json:

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

This file doesn't compile anything—it orchestrates the other configs using project references. This enables:

  • Faster incremental builds

  • Separate configurations for browser and Node.js code

  • Better IDE support

Create apps/frontend/tsconfig.app.json for browser code:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "paths": {
      "@/*": ["./src/*"],
      "#/*": ["../backend/src/*"]
    }
  },
  "include": ["src"]
}

Key points:

  • extends: Inherits strict options from the base config

  • lib: ["ES2022", "DOM", "DOM.Iterable"]: Includes browser APIs (DOM)

  • moduleResolution: "bundler": Optimized for bundlers like Vite

  • paths: Defines two aliases:

    • @/* for internal frontend imports

    • #/* for importing backend types (cross-workspace)

Cross-workspace type imports: The #/* path pointing to ../backend/src/* allows the frontend to import types directly from the backend:

// In frontend, you could import backend types like this:
import type { SomeApiResponse } from '#/types';

Create apps/frontend/tsconfig.node.json for Vite config:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2023"],
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "moduleDetection": "force",
    "noEmit": true
  },
  "include": ["vite.config.ts"]
}

This config is much smaller because it extends the base. It exists separately because vite.config.ts runs in Node.js, not the browser—notice there's no DOM in the lib array.

4.3 Create Vite Configuration

Create apps/frontend/vite.config.ts:

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

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '#': path.resolve(__dirname, '../backend/src'),
    },
  },
});

Important: Path aliases must be defined in both tsconfig.app.json (for TypeScript type checking) and vite.config.ts (for the bundler's module resolution). TypeScript handles type checking, while Vite handles the actual import resolution during development and build.

4.4 Create HTML Entry Point

Create apps/frontend/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node Monorepo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Vite uses index.html as the entry point. The <script type="module"> tag points to our main TypeScript file.

4.5 Create React Application

Create the main entry file at apps/frontend/src/main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

StrictMode helps identify potential problems by activating additional checks during development.

Create the App component at apps/frontend/src/App.tsx:

import { useEffect, useState } from 'react';

function App() {
  const [message, setMessage] = useState<string>('Loading...');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('http://localhost:3000/api/hello')
      .then(response => {
        if (!response.ok) {
          throw new Error('Failed to fetch');
        }
        return response.json();
      })
      .then(data => {
        setMessage(data.message);
      })
      .catch(err => {
        setError(err.message);
      });
  }, []);

  return (
    <div className="container">
      <h1>Node Monorepo</h1>
      {error ? (
        <p className="error">Error: {error}</p>
      ) : (
        <p className="message">{message}</p>
      )}
    </div>
  );
}

export default App;

This component:

  1. Uses useState to manage the message and error state

  2. Uses useEffect to fetch data from the backend when the component mounts

  3. Displays either the message or an error

Create the styles at apps/frontend/src/index.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family:
    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
    sans-serif;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.container {
  text-align: center;
  padding: 2rem;
  background: white;
  border-radius: 1rem;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}

h1 {
  color: #333;
  margin-bottom: 1rem;
}

.message {
  font-size: 1.25rem;
  color: #667eea;
}

.error {
  color: #e53e3e;
}

Step 5: Configure ESLint

ESLint 9 introduced the flat config format, replacing the legacy .eslintrc files. Create eslint.config.ts at the project root:

import jseslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig, globalIgnores } from 'eslint/config';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';

export default defineConfig(
  globalIgnores(['**/dist/**/*', '**/node_modules/**/*', '**/*.tsbuildinfo']),
  jseslint.configs.recommended,
  tseslint.configs.recommended,
  prettierConfig,
  {
    files: ['eslint.config.ts', 'commitlint.config.ts', 'prettier.config.ts'],
    languageOptions: {
      globals: globals.node,
      parserOptions: {
        tsconfigRootDir: import.meta.dirname,
        project: './tsconfig.json',
      },
    },
  },
  {
    files: ['apps/backend/**/*.{ts,tsx}'],
    languageOptions: {
      globals: globals.node,
      parserOptions: {
        tsconfigRootDir: import.meta.dirname,
        project: './apps/backend/tsconfig.json',
      },
    },
  },
  {
    files: ['apps/frontend/src/**/*.{ts,tsx}'],
    extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
    languageOptions: {
      globals: globals.browser,
      parserOptions: {
        tsconfigRootDir: import.meta.dirname,
        project: './apps/frontend/tsconfig.app.json',
      },
    },
  },
  {
    files: ['apps/frontend/vite.config.ts'],
    languageOptions: {
      globals: globals.node,
      parserOptions: {
        tsconfigRootDir: import.meta.dirname,
        project: './apps/frontend/tsconfig.node.json',
      },
    },
  }
);

Let's understand each part:

5.1 Global Ignores

globalIgnores(['**/dist/**/*', '**/node_modules/**/*', '**/*.tsbuildinfo']),

This replaces the old .eslintignore file. We ignore:

  • dist/: Build output directories

  • node_modules/: Dependencies

  • *.tsbuildinfo: TypeScript incremental compilation cache

5.2 Base Configurations

jseslint.configs.recommended,
tseslint.configs.recommended,
prettierConfig,

These apply to all files:

  • jseslint.configs.recommended: ESLint's recommended JavaScript rules

  • tseslint.configs.recommended: TypeScript-specific rules

  • prettierConfig: Must come after other configs to disable rules that conflict with Prettier

5.3 File-Specific Configurations

Each block targets specific files with appropriate settings:

Root config files:

{
  files: ['eslint.config.ts', 'commitlint.config.ts', 'prettier.config.ts'],
  languageOptions: {
    globals: globals.node,  // Node.js globals (process, __dirname, etc.)
    parserOptions: {
      tsconfigRootDir: import.meta.dirname,
      project: './tsconfig.json',  // Points to root tsconfig
    },
  },
},

Backend files:

{
  files: ['apps/backend/**/*.{ts,tsx}'],
  languageOptions: {
    globals: globals.node,
    parserOptions: {
      tsconfigRootDir: import.meta.dirname,
      project: './apps/backend/tsconfig.json',
    },
  },
},

Frontend source files:

{
  files: ['apps/frontend/src/**/*.{ts,tsx}'],
  extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
  languageOptions: {
    globals: globals.browser,  // Browser globals (window, document, etc.)
    parserOptions: {
      tsconfigRootDir: import.meta.dirname,
      project: './apps/frontend/tsconfig.app.json',
    },
  },
},

Note the React-specific plugins:

  • reactHooks.configs.flat.recommended: Enforces Rules of Hooks

  • reactRefresh.configs.vite: Ensures components are compatible with hot module replacement

Vite config file:

{
  files: ['apps/frontend/vite.config.ts'],
  languageOptions: {
    globals: globals.node,  // Vite config runs in Node.js
    parserOptions: {
      tsconfigRootDir: import.meta.dirname,
      project: './apps/frontend/tsconfig.node.json',
    },
  },
},

Why specify a project for each file pattern? TypeScript-ESLint can provide type-aware linting when it knows which tsconfig.json applies to each file. This enables catching more errors like unused variables that TypeScript alone might not flag.

Important: You must use eslint-plugin-react-hooks version 7 or higher. Earlier versions don't export a flat config (configs.flat.recommended is only available in v7+).

Step 6: Configure Prettier

Create prettier.config.ts at the project root:

import type { Config } from 'prettier';

const config: Config = {
  trailingComma: 'es5',
  singleQuote: true,
  arrowParens: 'avoid',
  endOfLine: 'crlf',
};

export default config;

Options explained:

OptionValuePurpose
trailingComma'es5'Adds trailing commas in objects and arrays. Creates cleaner git diffs when adding items.
singleQuotetrueUses single quotes for strings (common JavaScript convention)
arrowParens'avoid'Omits parentheses for single-parameter arrow functions: x => x instead of (x) => x
endOfLine'crlf'Windows line endings. Use 'lf' for Unix/macOS teams.

Create .prettierignore at the project root to exclude generated files:

**/dist/
**/*.tsbuildinfo
**/package-lock.json

Why ignore these?

  • dist/: Generated build output—formatting would be overwritten on next build

  • *.tsbuildinfo: Binary cache files

  • package-lock.json: Auto-generated by npm, formatting changes create noise in git history

Step 7: Configure Commitlint and Husky

7.1 Create Commitlint Configuration

Commitlint ensures all commit messages follow a consistent format. Create commitlint.config.ts at the project root:

import type { UserConfig } from '@commitlint/types';

const config: UserConfig = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'scope-enum': [2, 'always', ['backend', 'frontend', 'repo']],
    'subject-case': [2, 'always', ['sentence-case', 'lower-case']],
  },
};

export default config;

Understanding rule format: [level, applicable, value]

  • Level: 0 = disabled, 1 = warning, 2 = error

  • Applicable: 'always' (must match) or 'never' (must not match)

  • Value: The rule configuration

Our rules:

scope-enum: Restricts commit scopes to predefined values:

feat(backend): Add user authentication  ✓
fix(frontend): Resolve button styling   ✓
chore(repo): Update dependencies        ✓
feat(api): Add endpoint                 ✗ ('api' not in allowed scopes)

subject-case: Allows either sentence case or lowercase:

feat: Add new feature     ✓
feat: add new feature     ✓
feat: ADD NEW FEATURE     ✗

7.2 Conventional Commit Format

The conventional commit format is:

<type>(<scope>): <subject>

[optional body]

[optional footer]

Common types:

TypeWhen to use
featNew feature
fixBug fix
docsDocumentation changes
styleCode style changes (formatting, semicolons)
refactorCode changes that neither fix bugs nor add features
testAdding or modifying tests
choreMaintenance tasks (dependencies, build config)

7.3 Set Up Husky

Create the .husky directory and hook files:

mkdir -p .husky

Create .husky/pre-commit with the following content:

npm run lint
npm run format:check

This runs ESLint and checks Prettier formatting before each commit. If either fails, the commit is aborted, ensuring only properly formatted and linted code enters the repository.

Create .husky/commit-msg with the following content:

npx commitlint --edit $1

This validates the commit message against your commitlint rules. The $1 argument is the path to the temporary file containing the commit message.

Make the hooks executable (Unix/macOS):

chmod +x .husky/pre-commit
chmod +x .husky/commit-msg

Step 8: Install Dependencies and Test

8.1 Install All Dependencies

From the project root, run:

npm install

npm workspaces will:

  1. Install root devDependencies

  2. Install each workspace's dependencies

  3. Hoist shared dependencies to the root node_modules

  4. Create symlinks for workspace packages

  5. Run the prepare script, initializing Husky

8.2 Verify the Setup

Run ESLint to check for errors:

npm run lint

Check Prettier formatting:

npm run format:check

If there are formatting issues, fix them:

npm run format

8.3 Start the Development Servers

npm run dev

This runs both servers concurrently:

Open your browser to http://localhost:5173. You should see the "Node Monorepo" heading with the message "Hello World from Hono!" fetched from the backend.

8.4 Test the Build

npm run build

This builds both the backend (TypeScript compilation) and frontend (Vite production build).

8.5 Test Commit Hooks

Try making a commit to verify the hooks work:

git add .
git commit -m "feat(repo): Initial monorepo setup"

The pre-commit hook will run lint and format checks. The commit-msg hook will validate your commit message format.

Final Project Structure

node-monorepo/
├── apps/
│   ├── backend/
│   │   ├── src/
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json          # Extends tsconfig.base.json
│   └── frontend/
│       ├── src/
│       │   ├── App.tsx
│       │   ├── main.tsx
│       │   └── index.css
│       ├── index.html
│       ├── package.json
│       ├── tsconfig.json          # Project references
│       ├── tsconfig.app.json      # Extends tsconfig.base.json
│       ├── tsconfig.node.json     # Extends tsconfig.base.json
│       └── vite.config.ts
├── packages/
├── .husky/
│   ├── pre-commit
│   └── commit-msg
├── package.json
├── tsconfig.base.json             # Shared TypeScript options
├── tsconfig.json                  # Extends tsconfig.base.json
├── eslint.config.ts
├── prettier.config.ts
├── commitlint.config.ts
└── .prettierignore

This template provides a solid foundation for building full-stack TypeScript applications with modern tooling and best practices. You can find all the code here. Thanks, and happy coding