Table of Contents
- Background
- Structure Overview
- Next.js 12 Setup
- Step 1: Importing a TypeScript Utility Function
- Step 2: Importing a Hook
- Step 3: Importing a React Component
- Step 4: Importing a Client-Only Component
- Step 5: Importing a Side-Effect Component
- Step 6: Final Bundle Size
- Conclusion
- What's Next?
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" π):
- UI components (built with
shadcn/ui
) - Utility functions (TypeScript functions and React hooks)
- Client-only components (e.g.
window.location.hostname
) - 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 itsmain
field pointing tosrc/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