What is an importmap: no bundler needed

Free preview 4 min to read

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:

  1. 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.

  2. Bundlers: tools like Webpack that read import statements 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:

  1. A <script type="importmap"> block with the full module-to-URL mapping.
  2. A <link rel="modulepreload"> tag for each pin not marked preload: false (for performance).
  3. 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:

  1. Reads the importmap to learn where each module lives.
  2. Executes application.js as a module.
  3. Follows each import: loading controllers, @hotwired/turbo-rails, lexxy, and @rails/actiontext.
  4. 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, and to: map modules to files.
  • Propshaft (lesson 3): the asset server that fingerprints and serves files, Sprockets' modern replacement.
  • Stimulus controller loading (lesson 4): how eagerLoadControllersFrom auto-registers controllers.
  • Adding a dependency (lesson 5): the bin/importmap pin workflow 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.rb to browser execution.
Lesson 1 of 7 · free

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.

Type to start searching...