# Migrating from Webpack to ESM
Amber 2.0 replaces Webpack bundling with native browser ESM modules and import maps. This eliminates the need for Node.js, npm, and complex build configurations.
## Why Migrate?
| Aspect | Webpack | Asset Pipeline |
|--------|---------|----------------|
| Build time | 10-60+ seconds | None |
| Configuration | Complex webpack.config.js | Simple Crystal code |
| Dependencies | npm, node_modules | CDN or local files |
| Debugging | Source maps required | Native browser tools |
| Hot reload | Requires HMR plugin | Built-in browser support |
## Step-by-Step Migration
### 1. Add Asset Pipeline Shard
```yaml
# shard.yml
dependencies:
asset_pipeline:
github: amberframework/asset_pipeline
version: ~> 0.36.0
```
```bash
shards install
```
### 2. Create Asset Configuration
```crystal
# config/initializers/assets.cr
require "asset_pipeline"
JS_SOURCE_PATH = Path["src/javascript"]
JS_OUTPUT_PATH = Path["public/javascript"]
FRONT_LOADER = AssetPipeline::FrontLoader.new(
js_source_path: JS_SOURCE_PATH,
js_output_path: JS_OUTPUT_PATH
) do |import_maps|
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])
# Add your dependencies here (see Step 4)
import_maps << import_map
end
```
### 3. Update Layout
**Before (Webpack):**
```slang
doctype html
html
head
title My App
/ Webpack bundle
script src="/dist/bundle.js"
body
== content
```
**After (Asset Pipeline):**
```ecr
My App
<%= FRONT_LOADER.render_import_map_tag %>
<%= content %>
<%= FRONT_LOADER.render_stimulus_initialization_script %>
```
### 4. Migrate Dependencies
Find your npm dependencies and replace with CDN imports:
**package.json (before):**
```json
{
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"chart.js": "^4.4.0"
}
}
```
**Asset Pipeline (after):**
```crystal
# config/initializers/assets.cr
import_map.add_import(
"@hotwired/stimulus",
"https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
preload: true
)
import_map.add_import(
"jquery",
"https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm"
)
import_map.add_import(
"lodash",
"https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm"
)
import_map.add_import(
"chart.js",
"https://cdn.jsdelivr.net/npm/chart.js@4.4.0/+esm"
)
```
### 5. Migrate JavaScript Files
Move and update your JavaScript:
**Before (`src/assets/javascripts/application.js`):**
```javascript
// Webpack imports
import { Application } from "@hotwired/stimulus"
import HelloController from "./controllers/hello_controller"
const app = Application.start()
app.register("hello", HelloController)
```
**After (`src/javascript/controllers/hello_controller.js`):**
```javascript
// Native ESM - same syntax, no bundler
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["output"]
greet() {
this.outputTarget.textContent = "Hello!"
}
}
```
The Asset Pipeline handles Stimulus initialization automatically.
### 6. Register Controllers
```crystal
# config/initializers/assets.cr
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])
import_map.add_import(
"@hotwired/stimulus",
"https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm",
preload: true
)
# Register your Stimulus controllers
import_map.add_import("HelloController", "controllers/hello_controller.js")
import_map.add_import("DropdownController", "controllers/dropdown_controller.js")
import_map.add_import("ModalController", "controllers/modal_controller.js")
# Or auto-discover controllers
Dir.glob("#{JS_SOURCE_PATH}/controllers/*_controller.js").each do |file|
name = File.basename(file, ".js")
.split("_")
.map(&.capitalize)
.join
.gsub("Controller", "Controller") # Ensure "Controller" suffix
import_map.add_import(name, "controllers/#{File.basename(file)}")
end
```
### 7. Clean Up Webpack
```bash
# Remove Webpack files
rm webpack.config.js
rm -rf node_modules
rm package.json
rm package-lock.json
rm yarn.lock
# Remove Webpack from .gitignore entries
# Edit .gitignore to remove node_modules/, dist/, etc.
```
## Common Migration Patterns
### jQuery
**Webpack:**
```javascript
import $ from "jquery"
$(document).ready(() => {
$(".dropdown").dropdown()
})
```
**ESM:**
```crystal
# config/initializers/assets.cr
import_map.add_import("jquery", "https://cdn.jsdelivr.net/npm/jquery@3.7.1/+esm")
```
```javascript
// src/javascript/app.js
import $ from "jquery"
document.addEventListener("DOMContentLoaded", () => {
$(".dropdown").dropdown()
})
```
### React/Vue/Angular
For complex SPA frameworks, you have options:
**Option 1: Use ESM builds from CDN**
```crystal
# React
import_map.add_import("react", "https://esm.sh/react@18")
import_map.add_import("react-dom", "https://esm.sh/react-dom@18")
# Vue
import_map.add_import("vue", "https://unpkg.com/vue@3/dist/vue.esm-browser.js")
```
**Option 2: Keep Webpack for SPA, use ESM for Amber pages**
You can run both systems:
```crystal
# For Amber-rendered pages
FRONT_LOADER.render_import_map_tag
# For SPA routes, continue serving Webpack bundle
script src="/spa/dist/bundle.js"
```
### Lodash
**Webpack:**
```javascript
import _ from "lodash"
```
**ESM (use lodash-es for tree-shaking):**
```crystal
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm")
```
```javascript
// Import specific functions for smaller bundles
import { debounce, throttle } from "lodash"
```
### TypeScript
TypeScript requires compilation. Options:
1. **Pre-compile TypeScript** to JavaScript, serve ESM
2. **Use esbuild** for fast TypeScript compilation
3. **Keep minimal Webpack** for TypeScript only
```bash
# Option 2: esbuild
npm install -g esbuild
# Compile TypeScript to ESM
esbuild src/typescript/*.ts --outdir=public/javascript --format=esm
```
## Directory Structure Migration
**Before (Webpack):**
```
my_app/
├── src/
│ └── assets/
│ └── javascripts/
│ ├── application.js
│ └── controllers/
├── node_modules/
├── webpack.config.js
├── package.json
└── public/
└── dist/
└── bundle.js
```
**After (Asset Pipeline):**
```
my_app/
├── src/
│ └── javascript/
│ ├── controllers/
│ │ ├── hello_controller.js
│ │ └── dropdown_controller.js
│ ├── services/
│ │ └── api_service.js
│ └── utils/
│ └── helpers.js
├── config/
│ └── initializers/
│ └── assets.cr
└── public/
└── javascript/
└── (served directly - no build step)
```
## Handling CSS
Asset Pipeline focuses on JavaScript. For CSS, continue using your existing approach:
**Option 1: Plain CSS**
```ecr
```
**Option 2: Sass compilation**
```bash
# Compile Sass separately
sass src/stylesheets:public/css --watch
```
**Option 3: Tailwind CSS**
```bash
# Tailwind CLI (no npm required)
tailwindcss -i src/css/input.css -o public/css/output.css --watch
```
## Testing the Migration
### 1. Check Browser Console
Open browser DevTools and verify:
- No 404 errors for JavaScript files
- Import map loads correctly
- Stimulus controllers connect
### 2. Verify Functionality
Test interactive features:
- Form submissions
- Dropdowns/modals
- Dynamic content loading
- WebSocket connections
### 3. Performance Comparison
```bash
# Before (Webpack build time)
time npm run build
# After (no build!)
# Just save and refresh
```
## Rollback Plan
If issues arise, you can run both systems temporarily:
```crystal
# config/initializers/assets.cr
FRONT_LOADER = AssetPipeline::FrontLoader.new(...)
# In layout, conditionally use one or the other
if use_new_assets?
FRONT_LOADER.render_import_map_tag
else
# Fall back to Webpack bundle
raw %()
end
```
## Troubleshooting
### "Module not found" errors
Ensure the CDN URL uses ESM format:
```crystal
# Wrong - CommonJS format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash/lodash.js")
# Correct - ESM format
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm")
```
### Import map not loading
Import map must be in `` before any module scripts:
```ecr
<%= FRONT_LOADER.render_import_map_tag %>
```
### Stimulus controllers not connecting
Verify controller naming:
```crystal
# Import name must end with "Controller"
import_map.add_import("HelloController", "controllers/hello_controller.js") # Correct
import_map.add_import("hello", "controllers/hello_controller.js") # Wrong
```
### CORS errors
For CDN resources, most major CDNs handle CORS. If self-hosting:
```nginx
location /javascript/ {
add_header Access-Control-Allow-Origin *;
}
```