| name | propshaft |
| description | Expert guidance for the Propshaft asset pipeline in Rails 8+. Use when working with assets, stylesheets, CSS organization, JavaScript assets, import maps, asset precompilation, fingerprinting, images, fonts, CDN setup, or migrating from Sprockets. Covers file structure, stylesheet_link_tag, image_tag, @layer ordering, @import, CSS custom properties, asset helpers, deployment, and common Propshaft pitfalls. Trigger on "propshaft", "asset pipeline", "assets", "stylesheet", "javascript assets", "import maps", "asset precompile", "CSS organization", "sprockets migration", "fingerprinting", "stylesheet_link_tag", "image_tag", "asset_path". |
| allowed-tools | Read, Grep, Glob, Write, Edit, Bash(bin/rails assets:*), Bash(bin/rails server*), Bash(bundle exec rails assets:*) |
Rails Propshaft Asset Pipeline Expert
Manage assets in Rails 8+ applications using Propshaft — the modern, minimal asset pipeline that serves files directly without compilation or bundling.
When To Use This Skill
- Setting up or organizing assets in a Rails 8+ app
- Migrating from Sprockets to Propshaft
- Fixing broken asset paths, missing stylesheets, or fingerprinting issues
- Configuring CDN, import maps, or asset precompilation
- Organizing CSS/JS file structure with Propshaft conventions
Critical Mental Model
Propshaft is NOT Sprockets. Stop thinking in Sprockets patterns immediately:
| Sprockets (OLD — never use) | Propshaft (Rails 8 default) |
|---|
//= require directives | Not needed — all files auto-served |
//= require_tree . | Not needed — directory auto-included |
asset_path("image.png") in CSS | url("/image.png") in CSS |
image-url("bg.png") Sass helper | url("/bg.png") plain CSS |
manifest.js file | Not needed — no manifests |
| Sass/SCSS compilation | Plain CSS (or use cssbundling-rails) |
| Asset compilation step | No compilation — files served as-is |
config.assets.compile = true | Not applicable |
The #1 agent mistake: Using //= require or Sprockets-era helpers. Propshaft ignores these completely and they'll appear as literal text in your CSS/JS.
Philosophy
- Files are served directly — No compilation, no bundling, no transformation
- Fingerprinting is the core job — Propshaft digests files for cache busting
- CSS
@layer controls cascade — Not file load order or manifest declarations
- Browser-ready assets only — Propshaft expects CSS/JS the browser can consume
- Simplicity over power — Need bundling/transpilation? Add jsbundling-rails or cssbundling-rails
File Organization
Standard Directory Structure
app/assets/
├── stylesheets/
│ ├── application.css # Main entry — may just declare @layer order
│ ├── _global.css # Design tokens (CSS custom properties)
│ ├── reset.css # CSS reset
│ ├── base.css # Base element styles
│ ├── utilities.css # Utility classes
│ └── components/
│ ├── buttons.css
│ ├── cards.css
│ ├── forms.css
│ ├── alerts.css
│ └── navigation.css
├── images/
│ ├── logo.svg
│ └── icons/
│ ├── arrow.svg
│ └── check.svg
└── fonts/
├── inter-regular.woff2
└── inter-bold.woff2
Key Rules
- Every
.css file in app/assets/stylesheets/ is available to serve
- Underscore prefix (
_global.css) is convention only — Propshaft doesn't treat it specially
- No manifest file — Unlike Sprockets, no
manifest.js or application.css with //= require
- Subdirectories work —
components/cards.css is served as components/cards.css
CSS Organization with @layer
Declare Layer Order (Critical)
In application.css or _global.css, declare the cascade order:
@layer reset, base, components, utilities;
This single line controls ALL cascade priority. Layers listed later win over earlier ones.
Wrap Each File in Its Layer
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
}
@layer components {
.card {
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
}
@layer utilities {
.hidden { display: none !important; }
.text-center { text-align: center; }
}
Why @layer Matters
Without @layer, CSS specificity depends on file load order, which Propshaft doesn't guarantee. With @layer, the declared order always wins regardless of which file loads first:
reset — lowest priority (CSS reset, box-sizing)
base — element defaults (body, headings, links)
components — UI components (cards, buttons, forms)
utilities — highest priority (override anything)
Styles NOT in any @layer have the highest specificity — they beat all layers. Use this intentionally.
Design Tokens with CSS Custom Properties
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-muted: #6b7280;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--font-medium: 500;
--font-bold: 700;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
Dark mode with light-dark():
:root {
color-scheme: light dark;
--color-surface: light-dark(#ffffff, #1f2937);
--color-text: light-dark(#111827, #f9fafb);
}
Loading Stylesheets in Layouts
The :app Symbol (Recommended)
<%# Loads ALL stylesheets from app/assets/stylesheets/ %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
Loading Specific Files
<%# Load third-party CSS first, then app CSS %>
<%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
NEVER Use :all
<%# BAD — loads engine CSS too (e.g., Bulma from mission_control-jobs) %>
<%= stylesheet_link_tag :all %>
<%# GOOD — only your app's stylesheets %>
<%= stylesheet_link_tag :app %>
Explicit File Order (When Needed)
<%= stylesheet_link_tag "reset", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "base", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "main", "data-turbo-track": "reload" %>
Asset References
In Views (ERB)
<%# Images %>
<%= image_tag "logo.svg", alt: "Company Logo" %>
<%= image_tag "icons/arrow.svg", alt: "Arrow", class: "icon" %>
<%# Stylesheets %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%# Favicon %>
<%= favicon_link_tag "favicon.ico" %>
<%# Generic asset path %>
<%= asset_path("document.pdf") %>
In CSS
.hero {
background-image: url("/bg/pattern.svg");
}
.icon-check {
background-image: url("/icons/check.svg");
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-regular.woff2") format("woff2");
font-weight: 400;
}
Propshaft automatically rewrites url("/bg/pattern.svg") → url("/assets/bg/pattern-abc123.svg") during precompilation.
In JavaScript
Use the RAILS_ASSET_URL macro:
const trashIcon = RAILS_ASSET_URL("/icons/trash.svg");
JavaScript with Import Maps
Propshaft handles CSS/images/fonts. JavaScript is managed by importmap-rails (Rails 8 default):
app/javascript/
├── application.js # Entry point
└── controllers/ # Stimulus controllers
├── hello_controller.js
└── modal_controller.js
<%# In layout — this is importmap-rails, not Propshaft %>
<%= javascript_importmap_tags %>
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
Key distinction: Propshaft fingerprints JS files. Import maps resolve module names to URLs. They complement each other — Propshaft doesn't bundle or transform JS.
Images and Fonts
Images
Place in app/assets/images/. Reference with helpers:
<%= image_tag "logo.svg", alt: "Logo", width: 200 %>
<%= image_tag "icons/edit.svg", class: "icon", alt: "" %>
In CSS:
.logo { background-image: url("/logo.svg"); }
Fonts
Place in app/assets/fonts/. Declare with @font-face:
@font-face {
font-family: "Inter";
font-weight: 400;
font-style: normal;
font-display: swap;
src: url("/fonts/inter-regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-weight: 700;
font-style: normal;
font-display: swap;
src: url("/fonts/inter-bold.woff2") format("woff2");
}
body {
font-family: "Inter", system-ui, sans-serif;
}
Deployment and Precompilation
Precompile Command
RAILS_ENV=production rails assets:precompile
Or without real secrets:
RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 rails assets:precompile
This:
- Copies all assets from load paths to
public/assets/
- Fingerprints filenames (e.g.,
application-a1b2c3.css)
- Rewrites
url() references in CSS to digested paths
- Generates
.manifest.json mapping original → digested filenames
CDN Configuration
config.asset_host = ENV.fetch("CDN_HOST", nil)
Cache Headers (Web Server)
Nginx:
location ~ ^/assets/ {
expires 1y;
add_header Cache-Control public;
add_header ETag "";
}
Apache:
<Location /assets/>
Header unset ETag
FileETag None
ExpiresActive On
ExpiresDefault "access plus 1 year"
</Location>
Production Cache-Control
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=31536000"
}
Adding New Stylesheets
-
Create the file:
app/assets/stylesheets/components/modal.css
-
Wrap in appropriate @layer:
@layer components {
.modal { }
.modal-overlay { }
}
-
Done. Propshaft auto-includes it. No manifest to update, no require directive needed.
Third-Party / Engine CSS
For gems providing CSS (e.g., Action Text, Trix):
<%# Load third-party BEFORE :app so your styles can override %>
<%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
Additional Asset Paths
Rails.application.config.assets.paths << Rails.root.join("vendor/assets/stylesheets")
Rails.application.config.assets.paths << Emoji.images_path
Excluding Paths from Digestion
config.assets.excluded_paths = [Rails.root.join("app/assets/stylesheets")]
Debugging
bin/rails assets:reveal
Rails.application.assets.resolver.logical_paths.to_a
bin/rails assets:clobber
Common dev issue: If assets stop updating, you probably ran assets:precompile in development. The .manifest.json in public/assets/ tells Rails to use precompiled files. Fix with rails assets:clobber.
Common Mistakes
- Using
//= require — Propshaft ignores Sprockets directives entirely
- Using
image-url() or asset-url() — These are Sass/Sprockets helpers. Use plain url("/path")
- Using
:all instead of :app — :all loads engine CSS you don't want
- Missing
@layer declarations — Without layers, cascade depends on unpredictable file load order
- Expecting Sass compilation — Propshaft serves plain CSS. Use cssbundling-rails or dartsass-rails for Sass
- Precompiling in development — Creates
.manifest.json that freezes assets. Run assets:clobber
- Wrong URL format in CSS — Use
url("/icons/check.svg") not url("icons/check.svg") (root-relative)
- Confusing Propshaft and importmap-rails — Propshaft = CSS/images/fonts. Import maps = JavaScript modules
Migration from Sprockets
See reference.md in this skill directory for the complete migration checklist.
Quick summary:
bundle remove sprockets sprockets-rails sass-rails
- Delete
config/assets.rb and assets/config/manifest.js
bundle add propshaft (or upgrade to Rails 8 which includes it)
- Replace
//= require with nothing — files auto-load
- Replace
image-url() / asset-url() with url("/path")
- Replace
asset_path() in CSS with url("/path")
- Add
@layer declarations for cascade control
- Convert Sass to plain CSS (or add dartsass-rails/cssbundling-rails)