{"title":"Webpack to ESM Migration","description":"Move a working Webpack application to browser ESM and Amber's build-time asset manifest","section":"migration-guide","version":"v2","path":"migration-guide/webpack-to-esm","canonical_url":"https://amberframework.org/docs/v2/migration-guide/webpack-to-esm","markdown_url":"https://amberframework.org/docs/v2/migration-guide/webpack-to-esm.md","inherited":false,"content_markdown":"# Migrating from Webpack to ESM\n\nAmber V2 does not require Webpack, Node.js, npm, or a JavaScript framework. A\nserver-rendered application can use browser-native ESM and import maps. Removing\na working build tool is still a migration, not a prerequisite for upgrading the\nAmber runtime.\n\n> **Release boundary:** Amber `2.0.0-beta.5`, Amber CLI `2.0.6`, and\n> asset_pipeline `0.37.0` support the manifest contract below. Keep the existing\n> build whenever the application still needs Sass,\n> TypeScript, JSX, Vue single-file components, PostCSS, or another compiler.\n\n## Decide what Webpack currently owns\n\nBefore changing files, record:\n\n- every JavaScript entry point and dynamic chunk;\n- every imported stylesheet, image, font, and source map;\n- TypeScript, JSX, Sass, PostCSS, or other transformations;\n- environment-variable substitutions and compile-time flags;\n- development proxy and hot-module behavior;\n- public paths, CSP requirements, and CDN behavior; and\n- the command and artifact used by the current production deployment.\n\nRun the existing test, build, and browser smoke checks and keep that result as\nthe rollback baseline. Do not delete `package.json`, the lockfile, Webpack\nconfiguration, or the last known-good artifact yet.\n\n## Choose the smallest migration\n\n| Existing application | First move |\n|---|---|\n| Browser-ready JavaScript and CSS | Move them to the authored asset tree and use the manifest compiler |\n| A few replaceable npm packages | Prefer local reviewed ESM, or pin deliberate external ESM URLs |\n| TypeScript, JSX, Sass, or PostCSS | Keep that compiler; send its browser-ready output into the asset tree |\n| A large SPA | Keep its build and migrate server-rendered Amber pages independently |\n\nThe Asset Pipeline build is fast and deterministic, but it is still a build.\nIts job is content addressing and reference rewriting, not source-language\ntranspilation.\n\n## Target file map\n\n```text\nmy_app/\n├── shard.yml\n├── config/assets.cr\n├── scripts/build_assets.cr                        # only for older CLI build environments\n├── app/assets/\n│   ├── stylesheets/app.css\n│   ├── javascript/\n│   │   ├── app.js\n│   │   └── controllers/hello_controller.js\n│   ├── images/\n│   └── fonts/\n├── public/assets/manifest.json                    # generated\n└── src/views/layouts/application.ecr\n```\n\nSource control owns `app/assets/`. The compiler owns `public/assets/`. Runtime\nuploads belong in neither location.\n\n## 1. Add the released compiler\n\n**File: `shard.yml` — add the compatible official Asset Pipeline release under\nthe existing `dependencies:` key.**\n\n```yaml\ndependencies:\n  asset_pipeline:\n    github: amberframework/asset_pipeline\n    version: 0.37.0\n```\n\n**Run from: the application root.**\n\n```bash\nshards install\n```\n\n## 2. Configure the resolver and optional build wrapper\n\n**File: `config/assets.cr` — create the runtime resolver configuration.**\n\n```crystal\nAmber::Assets.configure(\n  manifest_path: \"public/assets/manifest.json\"\n)\n```\n\n**File: `scripts/build_assets.cr` — create this complete file only when the\nbuild environment cannot run Amber CLI `2.0.6`.**\n\n```crystal\nrequire \"asset_pipeline/static_assets\"\n\nAssetPipeline::StaticAssets::Compiler.new(\n  source_root: Path[\"app/assets\"],\n  output_root: Path[\"public/assets\"],\n  public_path: \"/assets\"\n).build\n```\n\nAmber CLI `2.0.6` exposes the same compiler as `amber assets build` and verifies\nits output with `amber assets check`. Do not load compiler construction from\n`config/assets.cr`; the running app needs the resolver, not build tooling.\n\n## 3. Move one vertical slice\n\nStart with one page rather than every asset.\n\n**Before: for example `src/assets/javascripts/hello_controller.js`.**\n\n```javascript\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  greet() {\n    this.element.textContent = \"Hello!\"\n  }\n}\n```\n\n**After: `app/assets/javascript/controllers/hello_controller.js` — move the\nbrowser-ready module here without changing its behavior.**\n\nIf it imports a local module with `./` or `../`, keep that relative import. The\ncompiler fingerprints the dependency and rewrites static imports, exports,\ndynamic imports, and source-map references. Bare names such as\n`@hotwired/stimulus` remain for the import map.\n\n**File: `app/assets/javascript/app.js` — create the browser entry point that\nstarts Stimulus and registers the migrated controller.**\n\n```javascript\nimport { Application } from \"@hotwired/stimulus\"\nimport HelloController from \"hello-controller\"\n\nconst application = Application.start()\napplication.register(\"hello\", HelloController)\n```\n\n**File: `app/assets/stylesheets/app.css` — move browser-ready CSS here.**\n\n```css\n@font-face {\n  font-family: \"Manrope\";\n  src: url(\"../fonts/Manrope-Variable.woff2\") format(\"woff2\");\n  font-display: swap;\n}\n\n.hero {\n  background: url(\"../images/hero.webp\") center / cover no-repeat;\n}\n```\n\nPlace the real font and image at the referenced relative paths. Local CSS\n`url(...)` and `@import` values are rewritten to fingerprinted URLs while query\nstrings and fragments are preserved. Root-relative, external, protocol-relative,\nfragment, `data:`, and `blob:` references remain unchanged.\n\nAsset Pipeline does not invent responsive images. Generate real widths and\nformats first, store each variant under `app/assets/images/`, and write a\n`srcset` or `<picture>` that names real logical files.\n\n**Run from: the application root after the source files and every referenced\nfont and image exist.**\n\n```bash\namber assets build\namber assets check\n```\n\nStop on a missing-reference error. Do not replace it with a raw path merely to\nmake the build pass.\n\n## 4. Load the configuration and update the layout\n\n**File: the application entry point, for example `src/my_app.cr` — keep the\ngenerated configuration wildcard or explicitly require the asset file.**\n\n```crystal\nrequire \"../config/*\"\n```\n\nCreating `config/assets.cr` is not enough if a migrated entry point never\nrequires it. The configuration wildcard must appear before controllers and\nmodels; otherwise require `../config/assets` after the file that loads Amber.\n\n**File: `src/views/layouts/application.ecr` — replace the selected page's raw\nasset tags with manifest-aware helpers.**\n\n```ecr\n<head>\n  <%= stylesheet_link_tag(\"stylesheets/app.css\") %>\n  <%= javascript_importmap_tag(\n    {\n      \"app\" => \"javascript/app.js\",\n      \"hello-controller\" => \"javascript/controllers/hello_controller.js\",\n      \"@hotwired/stimulus\" => \"https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm\"\n    },\n    preload: [\n      \"javascript/app.js\",\n      \"javascript/controllers/hello_controller.js\"\n    ]\n  ) %>\n</head>\n<body>\n  <%= content %>\n  <script type=\"module\">import \"app\";</script>\n</body>\n```\n\nUse only one import map. Local values are strict logical manifest paths;\nexternal URLs pass through. Prefer a reviewed self-hosted copy under\n`app/assets/javascript/vendor/` when availability or privacy cannot depend on a\nthird party.\n\n## 5. Keep necessary source compilers\n\nWhen Webpack still compiles TypeScript, Sass, or another source language, keep\nthat stage and give it a separate intermediate directory outside\n`public/assets/`. Then copy or generate the browser-ready result into\n`app/assets/` before the manifest build.\n\nFor example, a release sequence may be:\n\n```bash\nnpm ci\nnpm run build:browser-source\namber assets build\namber assets check\ncrystal spec\nshards build my_app --release\n```\n\nThe exact npm script is application-owned. Pin its toolchain and check its\noutput; do not claim “no Node” until no retained source file requires it.\n\n## 6. Verify before removing Webpack\n\n**Run from: the application root.**\n\n```bash\namber assets build\namber assets check\ncrystal spec\namber watch\n```\n\nFor every migrated page verify:\n\n1. the manifest contains its JavaScript, CSS, images, fonts, and other files;\n2. all HTML and rewritten CSS/JavaScript references use fingerprinted paths;\n3. response bytes and content types are correct;\n4. CSP, module imports, source maps, interactions, and reduced-motion behavior\n   still work;\n5. editing each asset class changes its URL after a rebuild;\n6. the runtime succeeds with the release directory read-only; and\n7. the prior complete release can still be started.\n\nOnly after all Webpack-owned transformations have replacements should you\nremove its tags, configuration, dependency manifest, lockfile, and generated\ndirectory in one reviewable change. Keep the repository history and prior\nrelease artifact as rollback evidence.\n\n## Deploy and roll back atomically\n\nBuild assets before the application binary. Package the binary, configuration,\n`public/assets/manifest.json`, and every emitted asset as one release. Publish\nthe manifest last during a build, but switch traffic only after the whole\nrelease verifies.\n\nFingerprint URLs may receive `public, max-age=31536000, immutable`. HTML,\nmanifest files, and unhashed legacy URLs must revalidate. Do not delete the\nprior release's assets while clients may still request its HTML.\n\nRollback means switching to the complete prior release—not rendering an old\nlayout against a new manifest. During a staged migration, routing separate pages\nto their existing Webpack tags and new manifest tags is safer than a runtime\nconditional that mixes two asset graphs in one document.\n\n## Troubleshooting\n\n### A logical asset is missing\n\nCompare the helper or import-map value with the path relative to `app/assets/`,\nthen rebuild. Do not paste a generated digest or raw `/assets/` URL into source.\n\n### A local import fails\n\nUse a relative specifier (`./` or `../`) for a local module imported by another\nsource module, or map a bare name in the one document import map. Confirm the\nemitted JavaScript contains the dependency's fingerprinted URL.\n\n### A font or background image fails\n\nResolve the source URL relative to the CSS file, not the project root. Confirm\nthe target is inside `app/assets/`, present in the manifest, and served with the\nmanifest's content type.\n\n### A remote module reports CORS or CSP errors\n\nFix the selected provider and application security policy, or self-host the\nreviewed ESM artifact. Do not add a blanket cross-origin response header to all\nself-hosted assets; same-origin modules do not need one."}