Back to Articles
ArchitectureRuntimeDeep Dive

Runtime Dependency Injection via esm.sh

January 30, 202612 min read

When AI builds apps, it doesn't know what packages it'll need until runtime. Traditional bundlers require a build step. We needed packages to load instantly, on demand, with zero configuration. esm.sh made this possible.

The Problem with Bundlers

In AppsAI, the AI might decide your app needs chart.js or date-fns or lucide-react. Traditional development would require:

  1. Run npm install package-name
  2. Wait for dependencies to resolve
  3. Run npm run build
  4. Wait for webpack/vite to bundle everything
  5. Deploy the new bundle

This takes minutes. When AI is building in real-time, you need packages to appear in milliseconds.

esm.sh: NPM as a CDN

esm.sh serves any npm package as an ES module, directly in the browser. No bundler. No build step. Just import:

// Traditional (requires bundler)
import { format } from 'date-fns'

// esm.sh (works directly in browser)
import { format } from 'https://esm.sh/date-fns@3.6.0'

When the AI adds a dependency, we just update the import URL. The browser fetches it from the CDN. Done.

The React Singleton Problem

Here's where it gets tricky. React must be a singleton—if you have two copies of React, hooks break catastrophically. The error looks like:

Invalid hook call. Hooks can only be called inside of the body
of a function component. This could happen because:
1. You might have mismatching versions of React and the renderer

If we load lucide-react from esm.sh, it bundles its own copy of React. Now we have two Reacts. Hooks die.

Import Maps to the Rescue

We solve this with import maps—a browser feature that rewrites import specifiers:

<script type="importmap">
{
  "imports": {
    "react": "/our-react-singleton.js",
    "react-dom": "/our-react-dom-singleton.js"
  }
}
</script>

Now when esm.sh packages try to import React from 'react', the browser redirects them to our singleton. One React. Hooks work.

Key insight: esm.sh supports externals via query params: ?external=react,react-dom. Combined with import maps, packages use your React instead of bundling their own.

The ForwardRef Trap

We hit a nasty bug with lucide-react. Icons are exported as React.forwardRef() components. Users write code like:

import * as Icons from 'lucide-react'

// Filter to only function components
const iconList = Object.entries(Icons)
  .filter(([_, Icon]) => typeof Icon === 'function')

The problem: React.forwardRef() doesn't return a function. It returns an object with $$typeof: Symbol(react.forward_ref). The filter removes all icons.

Our solution: wrap forwardRef components in plain functions while preserving React rendering:

function wrapForwardRef(Component) {
  // Check if it's a forwardRef
  if (Component?.$$typeof === Symbol.for('react.forward_ref')) {
    // Wrap in a plain function
    const Wrapped = (props) => <Component {...props} />
    // Copy metadata
    Wrapped.displayName = Component.displayName
    return Wrapped
  }
  return Component
}

Now typeof Icon === 'function' returns true, and React still renders the forwardRef correctly.

CSS Imports

Some packages need CSS. Swiper, for example:

import 'swiper/css'
import 'swiper/css/navigation'

esm.sh doesn't serve CSS directly. We detect CSS imports and redirect to jsdelivr:

// Detect CSS import
if (importPath.endsWith('.css')) {
  const cssUrl = `https://cdn.jsdelivr.net/npm/${pkg}/${path}`
  injectStylesheet(cssUrl)
  return // Don't try to import as JS
}

Chart.js Registration

Chart.js requires manual registration of components. Users don't want to write boilerplate. We auto-register when the package loads:

// After loading chart.js
import {
  Chart, CategoryScale, LinearScale,
  BarElement, LineElement, PointElement,
  ArcElement, Title, Tooltip, Legend
} from 'chart.js'

Chart.register(
  CategoryScale, LinearScale,
  BarElement, LineElement, PointElement,
  ArcElement, Title, Tooltip, Legend
)

The AI just writes new Chart(). Everything's pre-registered.

The Dependency Graph

When you import a package from esm.sh, it automatically resolves the entire dependency tree. If you import @tanstack/react-query, esm.sh fetches its dependencies too.

But here's the catch: transitive dependencies also need React externalized. We append ?external=react,react-dom to every esm.sh URL, ensuring the entire tree uses our singleton.

Performance

“Isn't loading from a CDN slow?”

Not really. esm.sh has global edge nodes. First load is ~100-300ms depending on package size. After that, the browser caches it indefinitely. We also preload common packages in the background.

Compare to bundler rebuild time: webpack takes 5-30 seconds. esm.sh takes 0 seconds after first load. For AI-generated apps that change constantly, this is a massive win.

Why Not Use a Bundler at All?

For production deployment, we do bundle. When you export your app, we run a proper build. But during development—when AI is building and you're iterating—runtime dependencies are the only way to get instant feedback.

The pattern:

  • Development: esm.sh for instant package availability
  • Production: Traditional bundler for optimization
  • Best of both worlds

Implementation Summary

Our dependency injection system:

  1. Detects imports in user code via regex
  2. Rewrites npm package imports to esm.sh URLs with ?external=react,react-dom
  3. Injects import map for React singleton
  4. Wraps forwardRef components for typeof compatibility
  5. Redirects CSS imports to jsdelivr
  6. Auto-registers special packages (chart.js, etc.)
  7. Caches loaded modules for singleton preservation

The result: AI can add any npm package, and it works instantly. No config. No build step. No waiting.

See it in action

Ask the AI to add a chart or icon library. Watch it appear instantly.

Try AppsAI