What is an importmap: no bundler needed
Most JavaScript frameworks require a build step: a bundler like Webpack, esbuild, or Vite that transforms, concatenates, and minifies your JavaScript before the browser can use it. Rails 8 takes a different approach: importmaps. No Node.js. No node_modules. No build step for JavaScript at all.
The problem importmaps solve
Before importmaps, loading JavaScript in the browser meant one of two things:
-
Script tags:
<script src="lodash.js">followed by<script src="app.js">. Every library needed its own tag, load order mattered, and everything lived in the global scope. Conflicts were common. -
Bundlers: tools like Webpack that read
importstatements in your code, resolve dependencies, concatenate everything into one or a few files, and generate fingerprinted output bundles. This works, but it adds complexity: config files, build pipelines, Node.js as a dependency, and long build times on large projects.
Importmaps give you the import syntax of option 2 without the build step of option 2. The browser does the module resolution natively.
How importmaps work
An importmap is a JSON object that tells the browser: "when you see import X from 'stimulus', fetch it from this URL instead." It's a native browser feature, and modern browsers need no polyfill for it.
<script type="importmap">
{
"imports": {
"@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js",
"application": "/assets/application-edf3ab74.js"
}
}
</script>
<script type="module">
import { Application } from "@hotwired/stimulus"
// Browser knows to fetch /assets/stimulus.min-4b1e420e.js
</script>
The <script type="importmap"> block maps module names to URLs. When a <script type="module"> runs an import statement, the browser looks up the name in the importmap and fetches the right file. No bundler needed: the browser is the module resolver.
How this app uses importmaps
This app's layout includes one helper call that generates all the JavaScript tags:
<%# app/views/layouts/application.html.erb %>
<%= javascript_importmap_tags %>
That single helper generates three things:
- A
<script type="importmap">block with the full module-to-URL mapping. - A
<link rel="modulepreload">tag for each pin not markedpreload: false(for performance). - A
<script type="module">tag that imports the application entrypoint.
The mapping comes from config/importmap.rb, which starts like this:
# config/importmap.rb
pin "application"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
Each pin statement maps a module name to a JavaScript file. When you write import "application" in the browser, it resolves to /assets/application-edf3ab74.js: the fingerprinted version of app/javascript/application.js.
The entrypoint: application.js
// app/javascript/application.js
import "controllers"
import "@hotwired/turbo-rails"
import "lexxy"
import "@rails/actiontext"
This is the starting point. When the browser loads the page, it:
- Reads the importmap to learn where each module lives.
- Executes
application.jsas a module. - Follows each
import: loadingcontrollers,@hotwired/turbo-rails,lexxy, and@rails/actiontext. - Each of those may import more modules, which the browser resolves through the same importmap.
flowchart TD
A["javascript_importmap_tags"] --> B["script type=importmap (JSON mapping)"]
A --> C["link rel=modulepreload (performance)"]
A --> D["script type=module (entrypoint)"]
D --> E["application.js"]
E --> F["controllers/index.js"]
E --> G["@hotwired/turbo-rails"]
E --> H["lexxy"]
E --> I["@rails/actiontext"]
F --> J["All Stimulus controllers"]
No node_modules, no package.json
This app has no node_modules directory, no package.json, no package-lock.json. JavaScript dependencies are managed through config/importmap.rb and either:
- Vendored files in
vendor/javascript/: downloaded once, committed to the repo. - CDN URLs: fetched directly from a CDN at runtime.
# Vendored: the file lives in vendor/javascript/
pin "codemirror", preload: false # => vendor/javascript/codemirror.js
# CDN: fetched from the network at runtime
pin "mermaid", to: "https://cdn.jsdelivr.net/npm/[email protected]/dist/mermaid.esm.min.mjs", preload: false
Both approaches avoid Node.js entirely. The trade-off: you don't get tree-shaking (removing unused code from libraries). For most Rails apps, HTTP/2 multiplexing and browser caching make this trade-off acceptable.
What this course covers
Over the next six lessons, you'll understand every layer of this app's asset pipeline:
- The pin API (lesson 2): how
pin,pin_all_from, andto:map modules to files. - Propshaft (lesson 3): the asset server that fingerprints and serves files, Sprockets' modern replacement.
- Stimulus controller loading (lesson 4): how
eagerLoadControllersFromauto-registers controllers. - Adding a dependency (lesson 5): the
bin/importmap pinworkflow for new JavaScript packages. - Production concerns (lesson 6): preloading, caching, CDN fallback, and Content Security Policy.
- Putting it together (lesson 7): the full pipeline from
config/importmap.rbto browser execution.
Keep going with lesson 2
The pin API: mapping modules to files
Covers pin, pin_all_from, CDN and vendored pins, the resolved JSON and the order Propshaft searches load paths.
A free account also gives you the quizzes, your progress and notes, and the discussion.