propshaft
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
@layercontrols 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
.cssfile inapp/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.jsorapplication.csswith//= require - Subdirectories work —
components/cards.cssis served ascomponents/cards.css
CSS Organization with @layer
Declare Layer Order (Critical)
In application.css or _global.css, declare the cascade order:
/* app/assets/stylesheets/application.css */
@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
/* app/assets/stylesheets/reset.css */
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
}
/* app/assets/stylesheets/components/cards.css */
@layer components {
.card {
padding: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
}
/* app/assets/stylesheets/utilities.css */
@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
/* app/assets/stylesheets/_global.css */
:root {
/* Colors */
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-muted: #6b7280;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Typography */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--font-medium: 500;
--font-bold: 700;
/* Borders */
--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
/* Use root-relative paths — Propshaft rewrites these to digested URLs */
.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:
// Propshaft transforms this during precompilation
const trashIcon = RAILS_ASSET_URL("/icons/trash.svg");
// Becomes: "/assets/icons/trash-54g9cbef.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 %>
# config/importmap.rb
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.jsonmapping original → digested filenames
CDN Configuration
# config/environments/production.rb
config.asset_host = ENV.fetch("CDN_HOST", nil)
# e.g., CDN_HOST=https://cdn.example.com
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/environments/production.rb
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
# config/initializers/assets.rb
Rails.application.config.assets.paths << Rails.root.join("vendor/assets/stylesheets")
Rails.application.config.assets.paths << Emoji.images_path
Excluding Paths from Digestion
# config/initializers/assets.rb
# Useful when using cssbundling-rails with Sass source files
config.assets.excluded_paths = [Rails.root.join("app/assets/stylesheets")]
Debugging
# List all available assets
bin/rails assets:reveal
# In Rails console
Rails.application.assets.resolver.logical_paths.to_a
# Clear precompiled assets (fix stale development assets)
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()orasset-url()— These are Sass/Sprockets helpers. Use plainurl("/path") - Using
:allinstead of:app—:allloads engine CSS you don't want - Missing
@layerdeclarations — 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.jsonthat freezes assets. Runassets:clobber - Wrong URL format in CSS — Use
url("/icons/check.svg")noturl("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.rbandassets/config/manifest.js bundle add propshaft(or upgrade to Rails 8 which includes it)- Replace
//= requirewith nothing — files auto-load - Replace
image-url()/asset-url()withurl("/path") - Replace
asset_path()in CSS withurl("/path") - Add
@layerdeclarations for cascade control - Convert Sass to plain CSS (or add dartsass-rails/cssbundling-rails)
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4routing
Expert guidance for defining routes in Rails applications. Use when adding routes, working with resources, nested routes, namespaces, path helpers, routes.rb, RESTful design, API routes, URL helpers, or any routing-related task. Covers resources, singular resources, nesting, namespace vs scope, constraints, concerns, member/collection routes, and route testing.
4form-helpers
Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.
4turbo
Expert guidance for building modern Rails UIs with Turbo (Drive, Frames, Streams). Use when implementing partial page updates, real-time broadcasts, turbo frames, turbo streams, hotwire patterns, turbo_frame_tag, turbo_stream responses, lazy loading frames, morphing, page refreshes, or any "turbo" related Rails feature. Covers Turbo Drive navigation, Turbo Frames for scoped updates, Turbo Streams for real-time HTML delivery, and Turbo 8 morphing.
4