Skip to content

4.3 — Dependency diet

Module 4 · Lesson 3 · 🟡 Intermediate · ~40 min

What you'll learn

  • The specific packages that bloat commerce bundles, with measured replacements
  • Barrel files: why they defeat tree‑shaking and how to fix them
  • Platform APIs that replace whole libraries at zero cost
  • A dependency review process that prevents the next 80 KB

The usual offenders

Sizes below are minified+gzipped and approximate — always measure in your own build, since tree‑shaking effectiveness varies with your bundler, config, and import style.

Package Typical cost Replacement Replacement cost
moment (+locales) ~70 KB Intl.DateTimeFormat / date-fns (targeted) 0 / ~3 KB
date-fns (barrel import) ~60 KB Deep imports, or Intl ~3 KB / 0
lodash (full) ~72 KB lodash-es deep imports, or native ~2 KB / 0
lucide-react (barrel) ~90 KB Deep imports / optimizePackageImports ~4 KB
react-icons (barrel) ~40–200 KB Deep imports (react-icons/fi) or inline SVG ~1 KB
axios ~14 KB fetch 0
uuid ~5 KB crypto.randomUUID() 0
classnames / clsx ~0.5–1 KB Keep — it's tiny and useful
query-string ~8 KB URLSearchParams 0
js-cookie ~2 KB document.cookie helper / cookies() server‑side 0
react-select ~35 KB Native <select> + custom styling ~2 KB
react-image-gallery ~38 KB CSS scroll‑snap + a few lines of JS ~3 KB
swiper ~60–120 KB CSS scroll‑snap ~3 KB
framer-motion ~50–110 KB CSS transitions / Web Animations API 0
chart.js / recharts ~80–160 KB Server‑rendered SVG, or lazy‑load 0 initial
validator ~30 KB Targeted regex / Intl / native validation ~0.5 KB
Full i18n runtime + all locales ~50–90 KB Server‑render text; ship one locale ~5 KB
core-js polyfills ~40–80 KB Tighten browserslist 0

None of these are bad libraries. They're the right choice in many apps. The point is that on a commerce critical path, where 200 KB is your entire budget, each one needs to justify itself.


Dates: the biggest easy win

Commerce apps format dates in a few places: delivery estimates, order dates, review dates, sale countdowns. Intl handles all of them, is built into every browser, and costs zero bytes.

// ❌ 70KB for "March 15, 2026"
import moment from 'moment';
moment(date).format('MMMM D, YYYY');

// ❌ Barrel import can pull far more than you use
import { format, formatDistance } from 'date-fns';

// ✅ 0 KB, locale-aware, and correct for every market you sell in
export function formatDate(date: Date | string, locale: string) {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric', month: 'long', day: 'numeric',
  }).format(new Date(date));
}

// ✅ "in 3 days" — relative time, 0 KB
export function formatRelative(date: Date, locale: string) {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const days = Math.round((date.getTime() - Date.now()) / 86_400_000);
  return rtf.format(days, 'day');
}

// ✅ Delivery window: "Arrives Mar 15 – Mar 18", 0 KB
export function formatDeliveryWindow(from: Date, to: Date, locale: string) {
  return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' })
    .formatRange(from, to);
}

Prices too — Intl.NumberFormat handles every currency and locale convention correctly, which hand‑rolled formatters famously do not:

// ✅ Correct for 40 markets, 0 KB
const formatters = new Map<string, Intl.NumberFormat>();

export function formatPrice(amountMinor: number, currency: string, locale: string) {
  const key = `${locale}:${currency}`;
  let fmt = formatters.get(key);
  if (!fmt) {
    // Cache: constructing Intl formatters is expensive (~0.5-2ms each).
    // In a 48-tile PLP that's up to 100ms if you construct per render.
    fmt = new Intl.NumberFormat(locale, { style: 'currency', currency });
    formatters.set(key, fmt);
  }
  return fmt.format(amountMinor / 100);
}

The formatter caching above is a real INP fix, not a micro‑optimization. Constructing Intl.NumberFormat inside a component that renders 48 times per filter change was responsible for ~60 ms of Aurora's PLP interaction latency.


Barrel files: the silent bundle killer

A barrel is an index file that re‑exports everything:

// packages/ui/src/index.ts
export * from './button';
export * from './modal';
export * from './carousel';
export * from './date-picker';
export * from './rich-text-editor';   // 60 KB, used on one admin page
// … 60 more
// Your component imports one thing…
import { Button } from '@aurora/ui';
// …but the bundler must evaluate the whole barrel to know what `Button` is.
// With side effects, dynamic re-exports, or a non-ESM build, tree-shaking fails
// and you get all 60 components.

Barrels also slow builds badly: every file importing the barrel forces the bundler to parse the entire module graph behind it. On a large monorepo this is minutes of build time.

Three fixes, in order of preference

1. Deep imports (best).

import { Button } from '@aurora/ui/button';
import { Modal } from '@aurora/ui/modal';

Requires the package to expose subpath exports:

{
  "name": "@aurora/ui",
  "sideEffects": false,
  "exports": {
    ".": "./dist/index.js",
    "./*": {
      "types": "./dist/*.d.ts",
      "import": "./dist/*.js"
    }
  }
}

"sideEffects": false is the flag that lets the bundler drop unused exports. If your package has CSS imports, list them explicitly instead of claiming no side effects:

{ "sideEffects": ["*.css", "./dist/polyfills.js"] }

2. optimizePackageImports (good, and zero migration effort).

Next.js rewrites barrel imports into deep imports at build time:

// next.config.ts
const config: NextConfig = {
  experimental: {
    optimizePackageImports: [
      '@aurora/ui',
      'lucide-react',
      'date-fns',
      'lodash-es',
      '@headlessui/react',
      'react-use',
    ],
  },
};

Many popular packages are optimized by default in recent Next.js versions; adding your own is still worth it. Measure before and after — the effect varies by package structure.

3. ESLint rule (prevention).

// eslint.config.js
export default [{
  rules: {
    'no-restricted-imports': ['error', {
      paths: [
        { name: '@aurora/ui', message: 'Use deep imports: @aurora/ui/button' },
        { name: 'lodash', message: 'Use lodash-es deep imports, or a native equivalent.' },
        { name: 'date-fns', message: 'Use Intl, or deep imports: date-fns/format' },
        { name: 'moment', message: 'Use Intl.DateTimeFormat.' },
      ],
      patterns: [
        { group: ['lucide-react'], message: 'Import icons individually: lucide-react/dist/esm/icons/x' },
      ],
    }],
  },
}];

Icons

Icon libraries are the most common single source of unexpected bundle weight, because the barrel import pattern is what every README shows.

// ❌ Pulls the whole icon set in many configurations
import { ShoppingCart, Heart, Search } from 'lucide-react';

// ✅ Option A: optimizePackageImports handles it (verify in the analyzer)

// ✅ Option B: your own SVG components — total control, ~300 bytes each,
//    and they work in Server Components with no client JS at all
// components/icons/cart.tsx
export function CartIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
         strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
         aria-hidden="true" {...props}>
      <path d="M3 3h2l2.4 12.3a2 2 0 0 0 2 1.7h7.7a2 2 0 0 0 2-1.6L21 8H6" />
      <circle cx="9" cy="20" r="1.5" />
      <circle cx="18" cy="20" r="1.5" />
    </svg>
  );
}

For a large icon set, generate components at build time from SVG files (SVGR or a small script), so each icon is a separate module and tree‑shaking works by construction.

Aurora's icon audit: 34 icons used across the site, lucide-react barrel costing 92 KB. Generated components: 4.1 KB total, and they became Server Components.


Carousels

Product galleries and "you may also like" rails don't need a JS carousel library. CSS scroll‑snap does it natively, with real momentum scrolling on touch, keyboard support, and no JS.

// components/product-carousel.tsx — Server Component, 0 KB of JS
export function ProductCarousel({ products }: { products: Product[] }) {
  return (
    <div
      className="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth
                 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      role="region"
      aria-label="Recommended products"
    >
      {products.map((p) => (
        <div key={p.id} className="w-[70%] shrink-0 snap-start sm:w-[45%] lg:w-[23%]">
          <ProductCard product={p} />
        </div>
      ))}
    </div>
  );
}

If you need arrow buttons, that's ~20 lines of client code operating on scrollBy:

'use client';
export function CarouselControls({ containerRef }: { containerRef: React.RefObject<HTMLDivElement> }) {
  const scroll = (dir: 1 | -1) => {
    const el = containerRef.current;
    if (!el) return;
    el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: 'smooth' });
  };
  return (
    <>
      <button onClick={() => scroll(-1)} aria-label="Previous"><ChevronLeft /></button>
      <button onClick={() => scroll(1)} aria-label="Next"><ChevronRight /></button>
    </>
  );
}

~3 KB total, versus 60–120 KB for a carousel library, and the scroll behavior is better because it's native.


Animation

framer-motion is excellent, and it costs 50–110 KB. On a commerce site, most animations are fades, slides, and scale effects that CSS does for free on the compositor.

// ❌ 60KB+ for a fade-in
import { motion } from 'framer-motion';
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} />

// ✅ CSS: 0 KB, runs on the compositor, doesn't compete with React for the main thread
<div className="animate-fade-in" />
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
.animate-fade-in { animation: fade-in 300ms ease-out both; }

/* Respect user preference — this is an accessibility requirement, not a nicety */
@media (prefers-reduced-motion: reduce) {
  .animate-fade-in { animation: none; }
}

For genuinely complex sequencing, the Web Animations API is built in:

element.animate(
  [{ transform: 'translateY(20px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }],
  { duration: 300, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'both' },
);

Keep an animation library if you have shared‑element transitions or gesture‑driven interactions that genuinely need it — but load it only on the routes that use it.


Internationalization

Full i18n runtimes ship a message parser, a plural‑rules engine, and often every locale's messages. For a Next.js app with Server Components, most of that is unnecessary.

// ✅ Translate on the server: the client receives strings, not a translation runtime
// lib/i18n.ts
import 'server-only';
import { cache } from 'react';

export const getMessages = cache(async (locale: string) => {
  // Only this locale's messages are ever loaded, on the server
  return (await import(`@/messages/${locale}.json`)).default;
});

// app/[locale]/p/[slug]/page.tsx
export default async function ProductPage({ params }) {
  const { locale, slug } = await params;
  const [t, product] = await Promise.all([getMessages(locale), getProduct(slug)]);
  return (
    <>
      <h1>{product.name}</h1>
      <AddToCartButton label={t.pdp.addToCart} />   {/* string, not a t() function */}
    </>
  );
}

Client components that genuinely need runtime translation get only the keys they use, passed as props. When you truly need a client‑side t() (dynamic strings, client‑only flows), ship a minimal implementation:

// ~400 bytes, handles interpolation and simple plurals
export function interpolate(template: string, vars: Record<string, string | number>) {
  return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
}
export const plural = (n: number, locale: string, forms: Record<string, string>) =>
  forms[new Intl.PluralRules(locale).select(n)] ?? forms.other;

Polyfills

Check what your build is transpiling for. Targeting old browsers costs bytes for users who don't exist.

// package.json
{
  "browserslist": ["chrome >= 111", "safari >= 16.4", "firefox >= 111", "edge >= 111", "not dead"]
}
# What does your current browserslist actually include?
npx browserslist
npx browserslist --coverage      # % of global users covered

Check your own analytics for the real distribution before tightening. On a commerce site, the answer is usually that <0.3% of revenue comes from browsers older than 2 years, and supporting them costs every other user 40–80 KB.

Next.js also ships a legacy polyfill bundle for older browsers. Verify it isn't being served to modern ones — in the Network panel, look for a polyfills-*.js chunk and check whether modern browsers download it.


The dependency review process

Prevention beats cleanup. Three controls:

1. A size gate on package.json changes:

# .github/workflows/dep-review.yml
on:
  pull_request:
    paths: ['package.json', 'package-lock.json']

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      # Fails if any route exceeds its budget (see 1.5)
      - run: npm run build && node scripts/check-bundle-budget.mjs
      - name: Require justification
        run: |
          echo "::notice::New dependency added. PR description must include:"
          echo "  - Bundle cost (client, gzipped)"
          echo "  - Alternatives considered (including native APIs)"
          echo "  - Whether it can be server-only or lazy-loaded"

2. A PR template question:

### New dependency checklist (delete if none added)
- [ ] Measured client bundle cost: ____ KB gzipped
- [ ] Considered a platform API alternative (Intl, URLSearchParams, crypto, WAAPI)
- [ ] Can it be server-only? (`import 'server-only'`)
- [ ] Can it be lazy-loaded behind an interaction?
- [ ] Does it support tree-shaking? (ESM + `sideEffects: false`)
- [ ] Maintenance: last release date, open critical issues

3. A quarterly audit:

npx depcheck                      # unused dependencies
npm ls --depth=0 | wc -l          # direct dependency count over time
npx npm-why <package>             # who pulls in a transitive dependency

Aurora's results

Change Before After Saved
momentIntl 70 KB 0 −70 KB
lucide-react barrel → generated SVG components 92 KB 4 KB −88 KB
@aurora/ui barrel → deep imports 74 KB 22 KB −52 KB
swiper → CSS scroll‑snap 71 KB 3 KB −68 KB
framer-motion → CSS animations 58 KB 0 −58 KB
i18n runtime → server translation 47 KB 6 KB −41 KB
axiosfetch 14 KB 0 −14 KB
browserslist tightened 52 KB polyfills 0 −52 KB
Total −443 KB

Six weeks of work by one engineer, with no feature changes and no visual regressions. INP p75 dropped 130 ms as a side effect of the reduced parse/compile and hydration cost.


Common mistakes

Mistake Cost
Barrel imports Whole libraries in the bundle
Assuming tree‑shaking works It fails silently with CJS, side effects, or dynamic re‑exports
Reaching for a library before checking the platform 30–100 KB for something Intl does
Constructing Intl formatters per render Real INP cost on list pages
Keeping a library for one function Copy the function
Loose browserslist 40–80 KB of polyfills for nobody
Adding dependencies without a size check Death by a thousand 8 KB additions
Never auditing Unused dependencies accumulate for years

Lab 4.3 — Dependency audit

  1. From your bundle analysis (4.1), list every client package over 15 KB.
  2. For each: check this lesson's table, check whether a platform API covers it, and check whether it could be server‑only.
  3. Fix barrels first — usually the largest single win, and it's a config change plus find/replace.
  4. Add optimizePackageImports for anything you can't restructure. Measure the delta.
  5. Add the ESLint no-restricted-imports rules so the barrels don't come back.
  6. Check browserslist and your real browser distribution.
  7. Run npx depcheck and remove what's unused.
  8. Add the dependency PR checklist and the size gate.

Checklist

  • No barrel imports from large packages
  • optimizePackageImports configured for what remains
  • sideEffects declared correctly in internal packages
  • Dates and prices via Intl, with cached formatters
  • Icons as individual modules or inline SVG, not a barrel
  • Carousels via CSS scroll‑snap
  • Animations via CSS/WAAPI on the critical path
  • i18n resolved server‑side
  • browserslist matches your real user base
  • ESLint rules prevent regressions
  • Dependency additions require a measured justification

Next: 4.4 Hydration cost