- Published on
Optimizing Client Bundles in Monorepos: Advanced Client/Server Architecture and Tree-Shaking
TL;DR: How I reduced client bundle size by ~35% implementing a smart client/server services architecture while maintaining robust shared code between frontend and backend.
The Challenge: Code Sharing Without Bundle Bloat
In modern universal applications, we face an architectural paradox: we want to maximize code sharing between frontend and backend (types, validations, constants), but we definitely don’t want backend business logic ending up in our client bundle.
The concrete problem:
// ❌ Naive import - entire service gets bundled client-side
import { UserService, UserType, USER_ROLES } from '@packages/services'
// We only want UserType and USER_ROLES on the frontend
// But UserService (with its DB dependencies) also gets bundled!
3-Layer Architecture: The Solution
Here’s the architecture I implemented to solve this problem:
├── packages/
│ ├── references/ # Layer 1: Constants & Base Types
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ ├── genders.ts
│ │ │ ├── countries.ts
│ │ │ └── contact-mediums.ts
│ │ └── package.json
│ ├── services/ # Layer 2: Business Logic with Smart Splitting
│ │ ├── src/
│ │ │ ├── client.ts # 🎯 Frontend entry point
│ │ │ ├── server.ts # 🎯 Backend entry point
│ │ │ └── services/
│ │ │ └── job/
│ │ │ ├── job-service.ts # Classes (server only)
│ │ │ ├── schemas.ts # Zod (client + server)
│ │ │ └── types.ts # Types (client + server) mostly infered fro zod schemas
│ │ └── package.json
│ └── database/ # Layer 3: Data Access
└── apps/
└── web/ # Next.js Application
Layer 1: References - The Common Foundation
// packages/references/src/genders.ts
export const GENDERS = ['male', 'female', 'other'] as const;
export type Gender = typeof GENDERS[number];
// packages/references/src/contact-mediums.ts
export const CONTACT_MEDIUMS = [
{ id: 'email', label: 'Email', icon: '📧' },
{ id: 'phone', label: 'Phone', icon: '📱' },
{ id: 'linkedin', label: 'LinkedIn', icon: '💼' }
] as const;
export type ContactMediumIds = typeof CONTACT_MEDIUMS[number]['id'];
Principle: Pure reference data, no logic, optimized for tree-shaking.
Layer 2: Services - Where the Magic Happens
This is where the innovation lives. Let’s look at the export configuration:
// packages/services/package.json
{
"exports": {
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.cjs"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
"require": "./dist/server.cjs"
}
}
}
Client entry point:
// packages/services/src/client.ts
export * from "./services/job/schemas"; // Zod schemas
export * from "./services/job/types"; // TypeScript types
// ❌ NO service class exports!
Server entry point:
// packages/services/src/server.ts
import "server-only"; // 🔒 Guarantees this code never goes client-side
export * from "./client"; // Types + Schemas
export * from "./services/job/job-service"; // + Service classes
Tree-Shaking Explained: The Internal Mechanics
How It Works Under the Hood
When you write:
// In your React component
import { jobServiceSchemas, RESUME_GENDERS } from "@packages/services/client"
Here’s what happens:
-
Static analysis (ESBuild/SWC):
Module Graph Analysis: @packages/services/client ├── ./services/job/schemas ✅ (used) ├── ./services/job/types ❌ (unused, eliminated) └── ./services/job/job-service ❌ (not in client.ts)
-
Dependency resolution:
RESUME_GENDERS → schemas.ts → @packages/references/genders ✅ Only genders.ts file is included
-
Final bundle:
// Bundled client-side const RESUME_GENDERS = ['male', 'female', 'other']; const resumeSchema = z.object({ gender: z.enum(RESUME_GENDERS) }); // ❌ NOT bundled client-side // class JobService { ... } // import { drizzle } from 'drizzle-orm'
tsup Configuration for Optimization
// packages/services/tsup.config.ts
export default defineConfig({
entry: ["src/client.ts", "src/server.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: false, // Important for tree-shaking
minify: true,
clean: true,
esbuildOptions(options) {
options.treeShaking = true;
}
});
Measurable Gains: Performance & Business Impact
Performance Metrics
Bundle Size Impact:
Before optimization:
├── Initial bundle: 1.2MB
├── Vendor chunk: 800KB
└── App chunk: 400KB
After optimization:
├── Initial bundle: 720KB (-40%) ✅
├── Vendor chunk: 650KB (-19%) ✅
└── App chunk: 280KB (-30%) ✅
Core Web Vitals:
- LCP: 2.1s → 1.6s (-24%)
- FID: 45ms → 28ms (-38%)
- CLS: 0.08 → 0.05 (-38%)
Maintenance Gains
Before (duplicated code):
// ❌ Frontend
const USER_ROLES = ['admin', 'user'] as const;
type UserRole = typeof USER_ROLES[number];
// ❌ Backend
const USER_ROLES = ['admin', 'user'] as const;
type UserRole = typeof USER_ROLES[number];
// Risk of desynchronization!
After (single source of truth):
// ✅ References package (shared)
export const USER_ROLES = ['admin', 'user'] as const;
export type UserRole = typeof USER_ROLES[number];
// ✅ Frontend & Backend use the same source
import { USER_ROLES, UserRole } from '@packages/references';
Trade-offs and Limitations: The Honest Analysis
Drawbacks
Initial Complexity:
Sometimes, you forgot to import from the right package (client or server). However, it is secured enough to break and dont expose your services on frontend.
Monitoring and Continuous Optimization
Recommended Tools
Bundle Analysis:
# Next.js Bundle Analyzer
npm install -D @next/bundle-analyzer
# Webpack Bundle Analyzer
npx webpack-bundle-analyzer build/static/js/*.js
Import Cost Monitoring:
// VS Code extension
{
"recommendations": [
"wix.vscode-import-cost"
]
}
ESLint Rules:
// .eslintrc.js
module.exports = {
rules: {
// Prevent server imports in client code
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@packages/services/server'],
message: 'Server imports not allowed in client code'
}
]
}
]
}
};