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
# shard.yml
dependencies:
asset_pipeline:
github: amberframework/asset_pipeline
version: ~> 0.36.0
shards install
2. Create Asset Configuration
# 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):
doctype html
html
head
title My App
/ Webpack bundle
script src="/dist/bundle.js"
body
== content
After (Asset Pipeline):
<!doctype html>
<html>
<head>
<title>My App</title>
<%= FRONT_LOADER.render_import_map_tag %>
</head>
<body>
<%= content %>
<%= FRONT_LOADER.render_stimulus_initialization_script %>
</body>
</html>
4. Migrate Dependencies
Find your npm dependencies and replace with CDN imports:
package.json (before):
{
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"chart.js": "^4.4.0"
}
}
Asset Pipeline (after):
# config/initializers/assets.cr
import_map.add_import(
"@hotwired/stimulus",
"https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm",
preload: true
)
import_map.add_import(
"jquery",
"https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)
import_map.add_import(
"lodash",
"https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)
import_map.add_import(
"chart.js",
"https://cdn.jsdelivr.net/npm/[email protected]/+esm"
)
5. Migrate JavaScript Files
Move and update your JavaScript:
Before (src/assets/javascripts/application.js):
// 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):
// 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
# config/initializers/assets.cr
import_map = AssetPipeline::ImportMap.new("application", Path["/javascript"])
import_map.add_import(
"@hotwired/stimulus",
"https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+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
# 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:
import $ from "jquery"
$(document).ready(() => {
$(".dropdown").dropdown()
})
ESM:
# config/initializers/assets.cr
import_map.add_import("jquery", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
// 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
# 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:
# 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:
import _ from "lodash"
ESM (use lodash-es for tree-shaking):
import_map.add_import("lodash", "https://cdn.jsdelivr.net/npm/[email protected]/+esm")
// Import specific functions for smaller bundles
import { debounce, throttle } from "lodash"
TypeScript
TypeScript requires compilation. Options:
- Pre-compile TypeScript to JavaScript, serve ESM
- Use esbuild for fast TypeScript compilation
- Keep minimal Webpack for TypeScript only
# 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
<link rel="stylesheet" href="/css/application.css">
Option 2: Sass compilation
# Compile Sass separately
sass src/stylesheets:public/css --watch
Option 3: Tailwind CSS
# 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
# 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:
# 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 %(<script src="/dist/bundle.js"></script>)
end
Troubleshooting
"Module not found" errors
Ensure the CDN URL uses ESM format:
# 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/[email protected]/+esm")
Import map not loading
Import map must be in <head> before any module scripts:
<head>
<%= FRONT_LOADER.render_import_map_tag %>
<!-- other head content -->
</head>
Stimulus controllers not connecting
Verify controller naming:
# 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:
location /javascript/ {
add_header Access-Control-Allow-Origin *;
}