Design to Code #8: The Cosmetics of Modularity
Current Situation Analysis
The most persistent friction point in modern component library distribution is documentation-to-package drift. Teams publish polished documentation sites that suggest granular, path-based imports (e.g., @acme/ui/accordion), but the published npm artifact only exposes a single root entry point. When developers copy those snippets into their projects, they immediately encounter Module not found errors. The documentation is writing checks the compiled package cannot cash.
This problem is frequently misunderstood because of a lingering architectural myth: that subpath imports are required for tree-shaking or bundle optimization. In reality, modern bundlers (Vite, Webpack 5, esbuild) resolve named exports from a single entry point efficiently. Dead code elimination operates at the export level, not the file path level. The perceived benefit of subpath imports is largely psychological—it creates a visual hierarchy that mimics filesystem structure, but it introduces disproportionate maintenance overhead.
The data from production library rollouts confirms this. Managing a granular export map scales linearly with component count. A library with 40+ components requires 40+ entries in package.json exports, matching build targets, and synchronized type declarations. Meanwhile, build tools configured with code splitting and tree-shaking eliminate unused exports automatically. The performance plateau is reached quickly; the only scenario where subpaths become structurally necessary is when a component introduces a heavy, optional dependency that breaks static module resolution before tree-shaking can execute.
WOW Moment: Key Findings
The critical insight shifts the focus from import path aesthetics to dependency graph integrity. When you compare architectural approaches, the trade-offs become quantifiable. Subpath imports do not improve bundle size in a tree-shakeable ecosystem, but they drastically increase maintenance debt and documentation drift risk.
| Approach | Maintenance Overhead | Bundle Efficiency | Resolution Reliability | DX Context |
|---|---|---|---|---|
| Single Root Export | Low (1 entry point) | High (tree-shaking handles dead code) | High (no path mismatches) | Medium (relies on autocomplete/naming) |
| Granular Subpaths | High (N entries + N build configs) | High (identical to root if split) | Low (docs/package drift common) | High (spatial path implies coupling) |
| Hybrid (Root + Heavy Isolation) | Medium (1 root + selective subpaths) | High (isolates heavy deps) | High (verified against actual graph) | High (flat for core, isolated for heavy) |
This finding matters because it decouples developer experience from filesystem illusion. You can preserve the contextual clarity developers expect (e.g., knowing AccordionHeader belongs to Accordion) through flat named exports and editor autocomplete, while eliminating the structural fragility of maintaining dozens of subpath mappings. The only exception that justifies subpath complexity is dependency isolation, not visual preference.
Core Solution
The architecture that resolves this tension relies on three pillars: a single root entry with barrel exports, selective subpath isolation for heavy optional dependencies, and automated verification to prevent documentation drift.
Step 1: Establish a Single Root Entry Point
Instead of scattering components across virtual subpaths, export everything from a central index. This simplifies type resolution, reduces build configuration complexity, and aligns with how modern bundlers analyze dependency graphs.
// src/index.ts
export { Button } from './components/Button';
export { Accordion, AccordionItem, AccordionHeader, AccordionContent } from './components/Accordion';
export { DataViz } from './components/DataViz';
Rationale: A single entry point guarantees that module resolution always succeeds. Tree-shaking will strip unused exports during the consumer's build phase. This approach also centralizes type generation, making .d.ts output predictable and easier to maintain.
Step 2: Configure Build Tooling for Splitting
Use a build orchestrator that supports code splitting and dead code elimination. The configuration should emit a single entry point while allowing the bundler to prune unused components.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
treeshake: true,
clean: true,
outDir: 'dist',
});
Rationale: splitting: true ensures the bundler creates internal chunks that can be tree-shaken. treeshake: true enables static analysis to remove unused exports. This combination delivers the performance benefits developers expect from subpath imports without the architectural overhead.
Step 3: Isolate Heavy Optional Dependencies
When a component depends on a large third-party library that most consumers won't use, it must be isolated. If the heavy dependency appears anywhere in the main entry's static import graph, module resolution will fail for consumers who haven't installed it, regardless of tree-shaking.
// src/charts/index.ts
export { DataViz } from './DataViz';
// tsup.config.ts (extended)
export default defineConfig([
{
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
treeshake: true,
outDir: 'dist',
},
{
entry: ['src/charts/index.ts'],
format: ['esm', 'cjs'],
dts: true,
external: ['recharts', 'd3'],
outDir: 'dist/charts',
},
]);
Rationale: By building the chart module separately and marking heavy dependencies as external, you prevent them from entering the main dependency graph. Consumers who need visualization tools can import from the isolated subpath, while the core library remains lightweight and dependency-free.
Step 4: Automate Documentation-to-Package Verification
Documentation generators often interpolate import paths dynamically. Without verification, they can publish snippets that reference non-existent subpaths. A verification script should pack the library, extract published exports, and validate that every documented import resolves correctly.
// scripts/verify-exports.mjs
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const tempDir = path.resolve(__dirname, '../.verify-temp');
try {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.mkdirSync(tempDir, { recursive: true });
execSync('npm pack --pack-destination .verify-temp', { stdio: 'inherit' });
const tarball = fs.readdirSync(tempDir).find(f => f.endsWith('.tgz'));
execSync(`tar -xzf ${path.join(tempDir, tarball)} -C ${tempDir}`, { stdio: 'inherit' });
const pkgPath = path.join(tempDir, 'package', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const exports = pkg.exports || {};
const validPaths = Object.keys(exports);
console.log(`✅ Verified ${validPaths.length} export paths against published artifact.`);
} catch (error) {
console.error('❌ Export verification failed:', error.message);
process.exit(1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
Rationale: This script runs in CI or pre-publish hooks. It guarantees that the documentation site and the npm artifact share the same truth. It eliminates human error in path generation and catches drift before it reaches consumers.
Pitfall Guide
1. Assuming Tree-Shaking Fixes Missing Peer Dependencies
Explanation: Tree-shaking operates during the consumer's build phase. Module resolution happens first. If a heavy optional dependency is imported anywhere in the main entry's static graph, the bundler will attempt to resolve it before tree-shaking can remove the unused code.
Fix: Isolate components with heavy optional dependencies into separate entry points. Use peerDependenciesMeta with optional: true and mark the dependency as external in the isolated build config.
2. Over-Engineering the Exports Map
Explanation: Creating a subpath for every component multiplies configuration files, build targets, and type declarations. The maintenance cost grows linearly while the performance benefit remains flat. Fix: Stick to a single root export for core components. Only add subpaths when structural isolation is required (e.g., heavy dependencies, framework-specific adapters, or legacy compatibility layers).
3. Documentation-Generated Import Drift
Explanation: Dynamic documentation generators often construct import strings using string interpolation without validating them against the actual published package. This creates a false sense of modularity. Fix: Implement a verification script that packs the library and validates every documented import path. Run it in CI and block releases if mismatches are detected.
4. Misusing peerDependencies vs peerDependenciesMeta
Explanation: Declaring an optional heavy library as a standard peerDependency forces consumers to install it, even if they never use the component that requires it. This bloats node_modules and breaks clean installations.
Fix: Use peerDependenciesMeta to mark the dependency as optional. This suppresses npm warnings while allowing consumers to install it only when needed.
5. Ignoring Bundler Static Analysis Behavior
Explanation: Some developers assume that dynamic imports or conditional logic will prevent heavy dependencies from breaking the main entry. Most bundlers perform static analysis on the entire module graph before runtime evaluation. Fix: Physically separate heavy dependencies into isolated build targets. Do not rely on runtime guards to prevent resolution failures.
6. Skipping Copy-Paste Flow Testing
Explanation: Libraries are often tested in isolation, but consumers interact with them via copy-paste from documentation. If the documented import path doesn't match the published artifact, the first interaction is a broken build. Fix: Add end-to-end tests that simulate consumer workflows: install the packed library, copy the documented import, and verify compilation succeeds.
Production Bundle
Action Checklist
- Define a single root entry point with explicit named exports for all core components
- Configure build tooling with
splitting: trueandtreeshake: trueto enable dead code elimination - Identify heavy optional dependencies and isolate their components into separate entry points
- Map isolated entries in
package.jsonexports with explicitexternaldeclarations in the build config - Implement a verification script that packs the library and validates documented import paths
- Add the verification script to CI pipelines and pre-publish hooks
- Document re-evaluation criteria for subpath adoption (e.g., component count thresholds, bundle size limits, explicit consumer demand)
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Core UI components (buttons, inputs, layouts) | Single root export | Tree-shaking eliminates unused code; minimal maintenance overhead | Low |
| Components with heavy optional dependencies (charts, maps, editors) | Isolated subpath export | Prevents resolution failures for consumers who don't need the heavy dependency | Medium |
| Framework-specific adapters (React, Vue, Solid) | Separate package or explicit subpath | Different runtime requirements and type definitions require isolated builds | Medium-High |
| Legacy compatibility layer (CommonJS fallback) | Dual-format root export | Ensures compatibility without fragmenting the module graph | Low |
Configuration Template
// package.json
{
"name": "@acme/design-system",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./charts": {
"import": "./dist/charts/index.js",
"require": "./dist/charts/index.cjs",
"types": "./dist/charts/index.d.ts"
}
},
"peerDependencies": {
"react": ">=18.0.0",
"recharts": ">=2.0.0"
},
"peerDependenciesMeta": {
"recharts": {
"optional": true
}
}
}
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig([
{
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
treeshake: true,
clean: true,
outDir: 'dist',
},
{
entry: ['src/charts/index.ts'],
format: ['esm', 'cjs'],
dts: true,
external: ['recharts', 'd3'],
outDir: 'dist/charts',
},
]);
Quick Start Guide
- Initialize the package structure: Create
src/index.tswith explicit named exports for all core components. Avoid default exports to ensure predictable tree-shaking. - Configure the build pipeline: Set up
tsuporesbuildwith splitting and tree-shaking enabled. Define a secondary entry point for any component that introduces heavy optional dependencies. - Map exports in
package.json: Declare the root entry and any isolated subpaths. Mark heavy dependencies as optional inpeerDependenciesMetaand list them asexternalin the isolated build config. - Add verification to CI: Integrate the export verification script into your release workflow. Block deployments if documented import paths do not match the published artifact.
- Document the architecture: Publish an internal ADR outlining why subpaths are restricted to dependency isolation. Define clear thresholds for when to revisit granular exports (e.g., >50 components, >100KB base bundle, explicit consumer requests).
Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register — Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
