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

May 29, 2025

Table of Contents

Background

At my company, we adopted a monorepo structure without using any dedicated monorepo tools like Nx, Turborepo, or pnpm workspaces.

Instead, we went with the most basic approach β€” local npm install β€” to make things work.

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 Next.js 12 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 start


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.


Next.js 12 Setup

Here's our Next.js 12 app structure:

root
β”œβ”€β”€ next12app
β”‚   β”œβ”€β”€ pages
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   └── next.config.js
└── 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: ❌

Error:

Module parse failed: Unexpected token (1:21)
You may need an appropriate loader...

βœ… Fix: Use next-transpile-modules

Update next.config.js:

const withTM = require("next-transpile-modules")(["shared"]);

module.exports = withTM({
  reactStrictMode: true,
});

Now everything works:

  • tsc --noEmit βœ…
  • next dev βœ…
  • next 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: Resolve Path Aliases

Option 1: Change imports to relative paths

// ❌ before
import { cn } from "@/lib/utils";

// βœ… after
import { cn } from "../../lib/utils";

Downside: Manual work for every new file or refactor.

Option 2: Configure path alias in the host app's tsconfig.json

"baseUrl": ".",
"paths": {
  "@/*": ["../shared/src/@/*"]
}

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

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


Step 4: Importing a Client-Only Component

import { ClientOnlyComponent } from "shared";

Commands results:

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

Error:

ReferenceError: window is not defined

βœ… Fix: Prevent SSR from rendering it

Option 1: Exclude from barrel and dynamically import

Barrel file:

// Don't export this
// export * from "./client-only-component";

Host app:

import dynamic from "next/dynamic";

const ClientOnlyComponent = dynamic(
  () => import("shared/src/client-only-component").then(mod => mod.ClientOnlyComponent),
  { ssr: false }
);

Option 2: Make the component SSR-aware

const isClient = typeof window !== "undefined";

export const ClientOnlyComponent = () => {
  if (!isClient) return null;
  return <div>{window.location.hostname}</div>;
};

⚠️ Warning: This approach may cause hydration mismatch warnings. Use cautiously.

➑️ We'll stick with Option 1.


Step 5: Importing a Side-Effect Component

  • tsc --noEmit: βœ…
  • next dev: βœ…
  • next build: βœ…

⚠️ D3 Side Effects

If you're using named imports like:

import { select } from "d3-selection";

…but rely on side-effect modules like d3-transition, you'll get:

TS2339: Property 'transition' does not exist...

βœ… Fix:

import "d3-transition";


Step 6: Final Bundle Size

  • Node server bundle: ~400 KB
  • Client bundle: ~300 KB

Unfortunately, tree-shaking is limited in Next.js 12 due to Webpack 4 under the hood. You'll likely see some unused code getting pulled in.


Conclusion

In this post, we demonstrated how to:

  • Set up a basic monorepo without dedicated tooling
  • Share TS utilities, hooks, and components across multiple host apps
  • Handle issues with path aliasing, SSR, and bundling
  • Work around Next.js 12 limitations with next-transpile-modules

While this setup does work, there are trade-offs:

  • Manual handling of path aliases
  • Fragile structure for client-only components
  • Limited tree-shaking with Webpack 4


What's Next?

In the next post, we'll look at Next.js 14.

Stay tuned!

Example code for this post is available at πŸ‘‰ GitHub: tanshinjie/monorepo-without-tooling