Exploring a Monorepo Setup with Next.js 12, 12+, and Vite without Monorepo Tooling - Vite

May 31, 2025

Table of Contents

Background

At our company, we adopted a monorepo setup without any monorepo toolingβ€”no Nx, Turborepo, or even pnpm workspaces.

Instead, we took the minimalist approach: local npm install between packages.

Frameworks in Our Setup

  • Host apps: Next.js 12, Next.js 14 (referred to as 12+), and Vite
  • Shared library: Built using Vite

Our setup successfully supports:

  • βœ… Hot reloading with a single dev server
  • βœ… Tree-shaking in the final bundle (works for Next.js 12+ and Vite only)

The shared library exports four kinds of features (which I call "stuffs" πŸ˜„):

  1. UI components (built with shadcn/ui)
  2. Utility functions (TypeScript functions and React hooks)
  3. Client-only components (e.g. window.location.hostname)
  4. Side-effect components (e.g. d3-transition)

These represent a good mix of typical front-end use cases: from pure TS functions to client-only components and side-effect-heavy modules.

In this post, I'll show you the exact setup and pitfalls we encountered β€” without using monorepo tooling β€” and how we solved them.

This blog walks through our Vite host app setupβ€”each section progressively introduces new types of exports into the host, checking their behavior at every step:

  • tsc --noEmit
  • npm run dev
  • npm run build && npm run preview


Structure Overview

root
β”œβ”€β”€ next12app
β”œβ”€β”€ next14app
β”œβ”€β”€ viteapp
└── shared
    β”œβ”€β”€ src
    β”‚   β”œβ”€β”€ @/components
    β”‚   β”œβ”€β”€ client-only-component
    β”‚   β”œβ”€β”€ side-effect-component
    β”‚   β”œβ”€β”€ utils
    β”‚   └── index.ts       # Barrel file
    β”œβ”€β”€ tsconfig.json
    └── vite.config.ts
  • The shared package exports all modules through a single barrel file (index.ts)
  • package.json in the shared folder has its main field pointing to src/index.ts
  • We'll refine this setup progressively as we hit roadblocks.


Vite Setup

root
β”œβ”€β”€ next12app
β”œβ”€β”€ next14app
β”œβ”€β”€ viteapp
β”‚   β”œβ”€β”€ src
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   └── vite.config.ts
└── shared
    β”œβ”€β”€ src
    β”‚   β”œβ”€β”€ @/components
    β”‚   β”œβ”€β”€ client-only-component
    β”‚   β”œβ”€β”€ side-effect-component
    β”‚   β”œβ”€β”€ utils
    β”‚   └── index.ts

The library is installed via:

npm install ../shared


Step 1: Importing a TypeScript Utility Function

import { add } from "shared";

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: βœ…
  • npm run build: βœ…


Step 2: Importing a Hook

import { useMediaQuery } from "shared";

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: ❌
  • npm run build: ❌


Step 3: Importing a React Component

import { Button } from "shared";

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: ❌
  • npm run build: ❌

Error:

Module not found: Can't resolve '@/lib/utils'

βœ… Fix

Option 1: Convert to relative imports

// from
import { cn } from "@/lib/utils";
// to
import { cn } from "../../lib/utils";

Downside: Manual work for every new file or refactor.

Option 2: Add alias in vite.config.ts

resolve: {
  alias: {
    "@": path.resolve(__dirname, "../shared/src/@"),
  }
}

Downside: Tight coupling to folder structure + host app can't reuse the @ alias.

➑️ We'll go with Option 2 for now.

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: βœ…
  • npm run build: ❌

Error:

TypeError: Cannot read properties of null (reading 'useState')

Fix: Explicitly alias react

resolve: {
  alias: {
    "@": path.resolve(__dirname, "../shared/src/@"),
    react: path.resolve(__dirname, "./node_modules/react"),
  }
}

βœ… Everything works again.

Step 4: Importing a Client-Only Component

import { ClientOnlyComponent } from "shared";

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: βœ…
  • npm run build: βœ…

Step 5: Importing a Side-Effect Component

import { SideEffectComponent } from "shared";

Commands results:

  • tsc --noEmit: βœ…
  • npm run dev: βœ…
  • npm run build: βœ…

Note: If you’re using d3-selection with transition, remember to import side-effects:

import "d3-transition";

Otherwise you'll see:

TypeError: .transition is not a function

Interestingly, tsc doesn’t catch this 🀷


Bundle Optimization

Initial output: 378.34 kB

Multiple react-dom copies detected.

Fix it by aliasing:

resolve: {
  alias: {
    "@": path.resolve(__dirname, "../shared/src/@"),
    react: path.resolve(__dirname, "./node_modules/react"),
    "react-dom": path.resolve(__dirname, "./node_modules/react-dom"),
  }
}

New output: 240.97 kB

Further improvement: Tree-shake d3-transition using:

"sideEffects": ["**/node_modules/d3-transition"]

Final output: 206.92 kB


Conclusion

  • Vite's dev server works out of the box and feels lightweight
  • Manual config is needed for tree-shaking and alias resolution
  • Still, a clean setup is achievable without monorepo tooling

β†’ Example code on GitHub