The letter A styled as Alchemists logo. lchemists
Published April 1, 2025 Updated May 26, 2026
Cover
Hanami Assets

The Hanami Assets (gem) and corresponding Hanami Assets (library) provide the core functionality for dynamically or statically compiling all assets. The goal of this article is to delve deeper into what both the gem and library can do for your Hanami application. You’ll learn how assets are managed both inside and outside of a Hanami application. Let’s get started!

Structure

The following is an example of what your asset structure might look like:

.
├── app
│   ├── assets
│   │   ├── css
│   │   │   └── app.css
│   │   ├── images
│   │   │   └── icon.svg
│   │   ├── js
│   │   │   └── app.js
│   │   └── pwa
│   │       └── manifest.webmanifest
├── config
│   ├── slices
│   │   ├── health.rb
│   │   ├── home.rb
│   │   └── tasks.rb
│   ├── assets.js
├── node_modules
├── public
│   ├── .well-known
│   │   └── security.txt
│   ├── assets
│   │   ├── app.css
│   │   ├── app.js
│   │   ├── assets.json
│   │   ├── icon.svg
│   │   └── manifest.webmanifest
│   └── assets.json
├── .node-version
├── package-lock.json
├── package.json

The key take away is that app/assets is where you organize and manage your assets while public/assets is where all compiled assets end up. The css and js subdirectories are for only bundling all of your stylesheets and JavaScript code as a single file. The images subdirectory is for images but could be renamed as anything. All other subdirectories are for ancillary assets which will end up in the root of public/assets as shown with the PWA manifest file.

⚠️ All files in app/assets must be organized within subdirectories because no file is allowed in the root directory. If you place a file in the root directory, like app/assets/bogus.txt, you’ll get an exception when compiling assets that might look like this: ENOTDIR: not a directory, scandir 'app/assets/bogus.txt' [plugin hanami-esbuild].

Configuration

You can configure esbuild by editing your config/assets.js as follows:

import * as assets from "hanami-assets";

await assets.run({
  esbuildOptionsFn: (arguments, options) => {
    return options;
  }
});

You can use the above as follows:

  • Use arguments.watch to change compile or watch behavior.

  • Use options to add build options.

You can customize further, like adding PostCSS support, via the following:

import * as assets from "hanami-assets";
import postcss from "esbuild-postcss";

await assets.run({
   esbuildOptionsFn: (arguments, options) => {
     const plugins = [...options.plugins, postcss()];

     return {
       ...options,
       plugins
     };
   },
 });

Static Assets

You can check if Hanami is serving static assets by querying the application config. Here are the defaults:

# Development
Hanami.app.config.assets.serve  # true

# Production
Hanami.app.config.assets.serve  # false

If you’d like to force the production environment to server static assets then add the following to your environment:

HANAMI_SERVE_ASSETS=true

This does mean you must ensure assets are compiled when deployed into production.

Packages

The package.json file defaults to using "type":"module" which defaults to ES Module style. This allows you to use *.js file extensions but also mean that Common JS style is only supported by using *.cjs extensions.

Content Delivery Network (CDN)

Use config.assets.base_url to configure a CDN for serving your assets.

# config/app.rb
module Demo
  class App < Hanami::App
    environment :production do
      config.assets.base_url = "https://unpkg.com/demo"
      config.actions.content_security_policy[:script_src] += " https://unpkg.com"
    end
  end
end

By default, there is no Content-Security-Policy configured for your app. So, when you add a CDN, you’ll need to allow your CDN to be included via the script_src. You can verify this is correct by inspecting your asset via the console:

HANAMI_ENV=production hanami console
Hanami.app["assets"]["app.js"].url
# "https://unpkg.com/demo/assets/app.js"

For added security (highly recommended) to protect against the CDN being compromised, you can enable Subresource Integrity (SRI) as follows:

module Demo
  class App < Hanami::App
    environment :production do
      config.assets.subresource_integrity = %i[sha256 sha512]
    end
  end
end

The above will produce produce the following script tag via the javascript_tag helper (but would work for the stylesheet_tag helper as well):

<script src="/assets/app-ZWPED95E.js"
        type="text/javascript"
        integrity="sha256-tiefM4FdRqoKcfpHBwZD73TCtsXaTjJBeHfaeHZQV7I= sha512-8uLjPXrpcYTzTThievp3RHl6GxiGYCFCCRWXIGsg0UuiWJ9YnUXIUrpQ8shyjDhzwLue2pX9VRXfMpfjjDEEnw=="
        crossorigin="anonymous">
</script>

Entry Points

Entry points correspond directly with esbuild entry points and allow you to define the starting point for building an asset bundle. Multiple entry points are supported but each must be named app.js. Using any name other than app.js is not allowed.

Default

The default entry point can be found via app/assets/js/app.js, which contains the following:

import "../css/app.css";

The above ensures app.css will be included in your asset bundle. You must add a new entry for each new stylesheet you want to add.

Multiples

To build multiple entry points, use nested directories. Example:

app/assets/css/dashboard/components.css
app/assets/css/dashboard/widgets.css
app/assets/js/dashboard/app.js

Then in app/assets/js/dashboard/app.js, you’d have the following entries:

import "../css/dashboard/components.css";
import "../css/dashboard/widgets.css";

The above will produce the following assets:

public/assets
├── dashboard
│   ├── app.css
│   ├── app.js
├── app.css
├── app.js
├── assets.json

This allows you to load specific assets which, in this case, is for the dashboard so you can have different assets for different pages or different aspects of your application without having to shove everything into a single file.

File Extensions

The default file extension for app.js is .js but other extensions are supported as well such as .mjs, .ts, .mts, .tsx, and .jsx.

Bundles

A bundle consists of multiple files compiled into a single file. Usually, this means your stylesheets (CSS) and JavaScripts. This improves performance by reducing the number of HTTP requests required to load all assets for a page.

Additionally, esbuild bundle building is extremely fast which includes minimization, tree shaking, and dead code removal.

Compiling assets involves using a single esbuild process for each slice (main app included) where each slice will pass it’s specific arguments to it’s own esbuild process. This, ultimately, equates to forking a Ruby process per slice.

Lastly, a assets.json (manifest) is automatically built for each bundle.

Compilation

Assets can be built differently based on environment. You can also build assets for multiple environments all at once which will result in files with identical content but with different file names. Example:

app-XJUCK2LB.js       # Production JavaScript.
app-XJUCK2LB.js.map   # Production JavaScript source map.
app-ZG54EGJK.css      # Production stylesheet.
app-ZG54EGJK.css.map  # Production stylesheet source map.
app.css               # Development stylesheet.
app.js                # Development JavaScript.

Run either of the following from the command line to build for different environments:

  • hanami assets watch: Builds development assets without unique hash suffixes. This will constantly watch for file changes and rebuild accordingly. This does not minify your assets or provide source maps. This is provided for you, by Hanamismith, via the Procfile.dev.

  • hanami assets compile: Builds production assets with unique hash suffixes for cache busting purposes along with minification and source maps. This is provided for you, by Hanamismith, via the Procfile.

⚠️ Both of the above commands support the --env option which, at the moment, is completely busted. This means you can’t compile assets for the development environment. The only way to do this is use to the watch command or, if using Overmind, you can restart your watched assets via overmind restart assets which is the equivalent of recompiling your development assets.

Due to the above commands being convenient wrappers around Node, this means you can drop down a lower level and run the above as follows (the --sri option is supported as well):

# Compile (production)
node config/assets.js -- --path=app --dest=public/assets

# Development (development)
node config/assets.js -- --path=app --dest=public/assets --watch

Console

Assets can be accessed directly via the console which gives you the ability inspect and/or debug each asset. Example:

Hanami.app["assets"]["app.css"]
#<Hanami::Assets::Asset:0x0000000149731ed8 @path="/assets/app.css", @base_url=#<Hanami::Assets::BaseUrl:0x0000000148b99418 @url="">, @sri=nil>

Hanami.app["assets"]["app.css"].url
# "/assets/app.css"

Stylesheets

None of the asset helpers are available to you when working with stylesheets. Instead, use pure CSS to reference your assets as relative paths. For example, let’s say you have the following CSS code:

/* app/assets/css/app.css */
.breadcrumb {
  &::after {
    content: url("../images/icons/info.svg");
    margin-left: 0.2rem;
  }
}

Notice the relative path image path: ../images/icons/info.svg. This works because the info.svg is located in the images folder relative to the app.css. To illustrate further, here’s both paths listed together:

app/assets/css/app.css
app/assets/images/icons/info.svg

When Hanami compiles your assets, you’ll see info.svg show up in your assets folder as:

public/assets/info-6M3WXAC3.svg

The cache busting file suffix of 6M3WXAC3 will be applied regardless of whether you are working in a development or production environment which is different than working with asset helpers via the views and templates. Despite this small difference, esbuild will ensure your asset is linked in the resulting app.css so your info.svg icon renders properly in the UI.

This behavior isn’t specific to images. This works for fonts and other assets you wish to use within your stylesheets.

Images

An odd quirk — and bug — is when adding new assets to your app/assets folder such as images, fonts, and so forth while running bundle exec hanami assets watch will not automatically pick up those new files. You’ll need to restart the process or manually compile your assets in order to see the changes applied. Otherwise, any asset Hanami is watching, will automatically be updated for you. Only unknown files added after time of launch will be ignored.

Views

The following asset helpers are available to your views as documented in the Hanami Views article:

  • asset_url: Answers URL for any asset.

  • javascript_tag: Answers <script> tag for JavaScript asset.

  • stylesheet_tag: Answers <link> tag for stylesheet asset.

  • image_tag: Answers <img> tag for image asset.

  • favicon_tag: Answers <link> tag for favicon.ico asset.

  • video_tag: Answers <video> tag for video asset.

  • audio_tag: Answers <audio> tag for audio asset.

You can also combine helpers with the assets component:

<%= tag.link title: "Demo: Manifest",
             rel: :manifest,
             href: assets["manifest.webmanifest"] %>

<%= tag.link title: "Demo: Stylesheet",
             rel: :stylesheet,
             href: assets["app.css"] %>

In most cases, each helper (i.e. assets[]) can accept the following:

  • Source: The asset name, URL, or asset object.

  • Options: Additional HTML attributes.

Context

As a convenience, the #assets component is automatically included and available to your parts and scopes by messaging the context for the asset you need. Example:

context.assets["demo.jpg"]

Debugging

To debug issues with asset compilation, edit your config/assets.js file and turning on verbose logging. Example:

await assets.run({
  esbuildOptionsFn: (arguments, options) => {
    options.logLevel = "verbose";

    return options;
  },
});

Node Packages

You can manage all of your package dependencies by running npm install to add packages to your package.json, importing those packages into your app.js (or whatever your specific entrypoint is), and adding <%= javascript_tag "app" %> to your application layout to leverage these packages in your application. Here’s a few examples using Alpine, htmx, and others.

Alpine

First, install the package:

npm install alpinejs

Then add the following to app.js or your specific entrypoint:

import Alpine from "alpinejs";

window.Alpine = Alpine;
Alpine.start();

Finally, ensure your JavaScript helper includes the defer property:

<%= javascript_tag "app", defer: true %>

The above is important because Alpine must wait for the HTML document to be fully parsed before it can be initialized to avoid errors in you browser console.

htmx

First, install the main htmx package and any extensions such as htmx Remove, htmx Select, and so forth:

npm install htmx.org
npm install htmx-ext-ss
npm install htmx-remove
npm install htmx-select

Then add the following to app.js or your specific entrypoint:

import htmx from "htmx.org";
window.htmx = htmx;

import "htmx-ext-sse";
import "htmx-remove";
import "htmx-select";

What’s important, is that you must import and define htmx before any extensions are imported.

SASS

First, install the packages:

npm install esbuild-sass-plugin sass

Then add the following to config/assets.js:

import * as assets from "hanami-assets";
import { sassPlugin } from "esbuild-sass-plugin";

await assets.run({
  esbuildOptionsFn: (args, esbuildOptions) => {
    esbuildOptions.plugins = [
      ...(esbuildOptions.plugins || []),
      sassPlugin()
    ];
    return esbuildOptions;
  }
});

Now you can import both CSS and SCSS files via app/assets/js/app.js. Example:

import "../css/app.css";
import "../css/demo.scss";

Progressive Web Apps (PWAs)

To add PWA support (or build any asset that is not CSS, JavaScript, and/or an image), you can use a subdirectory (or multiple subdirectories) for these assets. Hanamismith automatically adds PWA support to your newly generated application by default:

// app/assets/pwa/manifest.webmanifest
{
  "name": "Demo",
  "short_name": "Demo",
  "description": "A demo app.",
  "icons": [
    {
      "src": "https://your_site.com/icon.svg",
      "type": "image/svg+xml",
      "sizes": "any"
    }
  ],
  "display": "standalone",
  "start_url": "/",
  "scope": "/",
  "theme_color": "#000000"
}

This is a simple example — and can be customized further — but allows you to install your app as a PWA on your mobile device. There is a lot you can do with PWAs so having this baked in, by default, allows you to hit the ground running.

Slices

Each slice can have it’s own configuration and assets (i.e. a Hanami::Assets instance) component. Each slice’s assets is relative to that slice by default. This includes the assets component, view helpers, etc.

Configuration

To configure your slice, create a slices/<name>/config/assets.js. Then you can change behavior in the same way as documented in the Configuration section for the main application.

Structure

The file structure for slices mimics that of the main application by using an assets folder within the slice:

├── slices/recipes
│   └── assets
│       ├── css
│       │   └── app.css
│       └── js
│           └── app.js

The above will compile into the following:

public/assets
├── _recipes
│   ├── app.css
│   ├── app.js
│   └── assets.json

You’re not limited to what’s in your slice either. You can also access application assets in order to reduce duplication. For example, here’s the slice’s app.js referencing the application’s stylesheets:

import "../../../../app/assets/css/colors.css";
import "../css/portal.css";

The first line uses a relative path to the application’s color.css while the portal.css is specific to the slice. Knowing this means you can reduce duplication if you need some styles pulled from the application.

Imports

Any slice can use assets from other slices (including the main app) as follows:

  1. Import the assets component from the other slice.

  2. Include the imported assets component as a dependency in your slice’s view context.

  3. Reference the assets component directly in your views/helpers.

Here’s an example of what this looks like when using the assets from the main app within a slice called Demo. First, we start by importing the assets from the main application into the Demo slice:

# config/slices/demo.rb
module Demo
  class Slice < Hanami::Slice
    import keys: ["assets"], from: Hanami.app.container, as: :main
  end
end

Next, we include the main app’s assets component within the Demo slice’s view context:

# slices/demo/views/context.rb
module Demo
  module Views
    class Context < Hanami::View::Context
      include Deps[main_assets: "main.assets"]
    end
  end
end

Finally, we can make use of the main app’s assets when using the view helpers within the Demo slice’s templates:

# slices/demo/templates/layouts/app.html.erb
<%= favicon_tag main_assets["logo/black.svg"], rel: :icon, type: "image/svg+xml" %>

💡 This is configured for you via Hanamismith for which you can apply to other slices as needed.

Sharing

Since each slice has it’s own assets container, you can use config.shared_app_component_keys to share keys amongst slices. This needs to be configured at the app level. Example:

module Demo
  class App < Hanami::App
    config.shared_app_component_keys += ["logger"]
  end
end

The above will ensure the logger object will be available to all slices as an injectable dependency.

Standalone

You can build assets for non-Hanami projects. For example, one use case is for static site generation where you always build and deploy static assets. You only need the Hanami Assets (library) instead of the Hanami Assets (gem) to make this possible. Here’s the structure you could use:

.
├── lib
│   ├── assets
│   │   ├── css
│   │   │   └── app.css
│   │   └── js
│   │       └── app.js
│   ├── config
│   │   └── assets.js
├── package.json

Given the above, you could populate each file as follows:

/* lib/assets/css/app.css */
* {
  margin: 0;
}
// lib/assets/css/app.js
import "../css/app.css";
// lib/config/assets.js
import * as assets from "hanami-assets";

await assets.run();
// package.json
{
  "name": "demo",
  "description": "A demo application.",
  "type": "module",
  "dependencies": {
    "hanami-assets": "^2.2.0"
  }
}

Finally, you can build your assets with this command:

node lib/config/assets.js -- --path=lib --dest=public/assets

The above will build your assets in the public/assets folder while using lib as the source. The lib/assets structure cannot be changed because Hanami requires a specific structure but you can put the lib/config/asset.js file wherever you like as well as change the destination of where your assets are built.

You are not limited to only the --path and --dest arguments as assets.js also accepts the --watch and --sri arguments for further customization.

Specification

A Hanami Assets Specification exists for anyone wanting to replace the default asset bundler with your own custom asset bundler while adhering to the same interface as required for a Hanami application.

Conclusion

You’ve learned how to configure and use assets for your Hanami application, specific slices, and even compile assets outside of a Hanami application. Hopefully, this will level you up so you can use your assets more effectively. Enjoy!