DEV Community

Cover image for Rails 8 CRUD: Modern Development Guide 2025
Sulman Baig
Sulman Baig

Posted on • Edited on • Originally published at sulmanweb.com

Rails 8 CRUD: Modern Development Guide 2025

When our team decided to upgrade to Rails 8, we chose a more Rails-native approach using importmap for JavaScript management. This decision aligned perfectly with Rails' philosophy of convention over configuration, and I'm excited to share how this choice shaped our development experience.

Initial Setup and Modern Stack Choices

Let's start with setting up our Rails 8 project:

rails new modern_platform \ --css tailwind \ --database postgresql \ --skip-test \ --skip-system-test 
Enter fullscreen mode Exit fullscreen mode

Why no --javascript flag? Rails 8 comes with importmap by default, which I've found to be a game-changer for managing JavaScript dependencies. Here's how we configured our importmap:

# config/importmap.rb pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" # Third-party packages we're using pin "chart.js", to: "https://ga.jspm.io/npm:chart.js@4.4.1/dist/chart.js" pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.9/src/index.js" pin "trix" pin "@rails/actiontext", to: "actiontext.js" # Local JavaScript modules pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/components", under: "components" 
Enter fullscreen mode Exit fullscreen mode

Modern JavaScript Organization

One of the benefits of importmap is how naturally it fits with module-based JavaScript. Here's how we structure our JavaScript:

// app/javascript/controllers/post_form_controller.js import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" export default class extends Controller { static targets = ["form", "preview"] static values = { previewUrl: String } async preview() { const formData = new FormData(this.formTarget) try { const response = await post(this.previewUrlValue, { body: formData }) if (response.ok) { const html = await response.text this.previewTarget.innerHTML = html } } catch (error) { console.error("Preview failed:", error) } } } 
Enter fullscreen mode Exit fullscreen mode

Component-Based Architecture

We've embraced ViewComponent with Stimulus, creating a powerful combination for reusable UI components:

# app/components/rich_text_editor_component.rb class RichTextEditorComponent < ViewComponent::Base attr_reader :form, :field def initialize(form:, field:) @form = form @field = field end def stimulus_controller_options { data: { controller: "rich-text-editor", rich_text_editor_toolbar_value: toolbar_options.to_json } } end private def toolbar_options { items: [ %w[bold italic underline strike], %w[heading-1 heading-2], %w[link code], %w[unordered-list ordered-list] ] } end end 
Enter fullscreen mode Exit fullscreen mode
<!-- app/components/rich_text_editor_component.html.erb --> <div class="rich-text-editor" <%= stimulus_controller_options %>> <%= form.rich_text_area field, class: "prose max-w-none", data: { rich_text_editor_target: "editor", action: "trix-change->rich-text-editor#onChange" } %> <div class="mt-2 text-sm text-gray-500" data-rich-text-editor-target="counter"> 0 characters </div> </div> 
Enter fullscreen mode Exit fullscreen mode
// app/javascript/controllers/rich_text_editor_controller.js import { Controller } from "@hotwired/stimulus" import Trix from "trix" export default class extends Controller { static targets = ["editor", "counter"] static values = { toolbar: Object, maxLength: Number } connect() { this.setupToolbar() this.updateCounter() } onChange() { this.updateCounter() } updateCounter() { const text = this.editorTarget.value this.counterTarget.textContent = `${text.length} characters` } setupToolbar() { if (!this.hasToolbarValue) return const toolbar = this.editorTarget .querySelector("trix-toolbar") // Customize toolbar based on configuration this.toolbarValue.items.forEach(group => { // Toolbar customization logic }) } } 
Enter fullscreen mode Exit fullscreen mode

Chart.js Integration with Importmap

Here's how we handle data visualization using Chart.js through importmap:

// app/javascript/controllers/analytics_chart_controller.js import { Controller } from "@hotwired/stimulus" import { Chart } from "chart.js" export default class extends Controller { static values = { data: Object, options: Object } connect() { this.initializeChart() } initializeChart() { const ctx = this.element.getContext("2d") new Chart(ctx, { type: "line", data: this.dataValue, options: { ...this.defaultOptions, ...this.optionsValue } }) } get defaultOptions() { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom" } } } } } 
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations with HTTP/2

One advantage of importmap is its excellent performance with HTTP/2. Here's how we optimize our asset delivery:

# config/environments/production.rb Rails.application.configure do # Use CDN for importmapped JavaScript config.action_controller.asset_host = ENV["ASSET_HOST"] # Enable HTTP/2 Early Hints config.action_dispatch.early_hints = true # Configure importmap hosts config.importmap.cache_sweepers << Rails.root.join("app/javascript") # Preload critical JavaScript config.action_view.preload_links_header = true end 
Enter fullscreen mode Exit fullscreen mode

Testing JavaScript Components

We use Capybara with Cuprite for JavaScript testing:

# spec/system/posts_spec.rb RSpec.describe "Posts", type: :system do before do driven_by(:cuprite) end it "previews post content", js: true do visit new_post_path find("[data-controller='post-form']").tap do |form| form.fill_in "Content", with: "**Bold text**" form.click_button "Preview" expect(form).to have_css( ".preview strong", text: "Bold text" ) end end end 
Enter fullscreen mode Exit fullscreen mode

Deployment Considerations

Our production setup leverages HTTP/2 and CDN caching:

# nginx.conf server { listen 443 ssl http2; # Enable asset caching location /assets/ { expires max; add_header Cache-Control public; } # Early hints for importmapped JavaScript location / { proxy_pass http://backend; http2_push_preload on; } } 
Enter fullscreen mode Exit fullscreen mode

Parallel Query Execution: A Game-Changer

One of the most exciting features we discovered in Rails 8 was parallel query execution. During our performance optimization sprint, this became a crucial tool for handling complex dashboard pages:

# app/controllers/dashboards_controller.rb class DashboardsController < ApplicationController def show # Execute multiple queries asynchronously posts_future = fetch_recent_posts.load_async comments_future = fetch_pending_comments.load_async analytics_future = ActiveRecord::Base.async_exec { fetch_analytics_data } notifications_future = fetch_user_notifications.load_async # Resolve the futures posts = posts_future.value comments = comments_future.value analytics = analytics_future.value notifications = notifications_future.value respond_to do |format| format.html do render locals: { posts: posts, comments: comments, analytics: analytics, notifications: notifications } end format.turbo_stream do render turbo_stream: [ turbo_stream.update("dashboard-posts", partial: "posts/list", locals: { posts: posts }), turbo_stream.update("dashboard-analytics", partial: "analytics/summary", locals: { data: analytics }) ] end end end private def fetch_recent_posts Post.visible_to(current_user) .includes(:author, :categories) .order(published_at: :desc) .limit(10) end def fetch_pending_comments Comment.pending_review .includes(:post, :author) .where(post: { author_id: current_user.id }) .limit(15) end def fetch_analytics_data AnalyticsService.fetch_dashboard_metrics( user: current_user, range: 30.days.ago..Time.current ) end def fetch_user_notifications current_user.notifications .unread .includes(:notifiable) .limit(5) end end 
Enter fullscreen mode Exit fullscreen mode

To make this even more powerful, we integrated it with Stimulus for real-time updates:

// app/javascript/controllers/dashboard_controller.js import { Controller } from "@hotwired/stimulus" import { Chart } from "chart.js" export default class extends Controller { static targets = ["analytics", "posts"] connect() { this.initializeCharts() this.startRefreshTimer() } disconnect() { if (this.refreshTimer) { clearInterval(this.refreshTimer) } } async refresh() { try { const response = await fetch(this.element.dataset.refreshUrl, { headers: { Accept: "text/vnd.turbo-stream.html" } }) if (response.ok) { Turbo.renderStreamMessage(await response.text()) } } catch (error) { console.error("Dashboard refresh failed:", error) } } startRefreshTimer() { this.refreshTimer = setInterval(() => { this.refresh() }, 30000) // Refresh every 30 seconds } initializeCharts() { if (!this.hasAnalyticsTarget) return const data = JSON.parse(this.analyticsTarget.dataset.metrics) this.createAnalyticsChart(data) } createAnalyticsChart(data) { const ctx = this.analyticsTarget.getContext("2d") new Chart(ctx, { type: "line", data: data, options: { responsive: true, maintainAspectRatio: false, animations: { tension: { duration: 1000, easing: 'linear' } } } }) } } 
Enter fullscreen mode Exit fullscreen mode

The combination of parallel queries and Turbo Streams gave us impressive performance improvements:

  1. Dashboard load times dropped by 47%
  2. Database connection usage became more efficient
  3. Real-time updates felt smoother with optimistic UI updates

Learning Journey and Trade-offs

Moving to importmap wasn't without challenges. Here's what we learned:

  1. Simplified Dependency Management: No more yarn/npm complexity
  2. Better Caching: HTTP/2 multiplexing improved load times
  3. Module Patterns: Encouraged cleaner JavaScript organization
  4. Development Experience: Faster feedback loop without build steps

Looking Forward

Rails 8 with importmap has transformed our development workflow. The native integration with Hotwire and Stimulus, combined with HTTP/2 optimizations, has given us a modern, maintainable, and performant application stack.

Stay tuned for more articles on our Rails 8 journey. Feel free to reach out with questions or share your own importmap experiences!


Happy Coding!


Originally published at https://sulmanweb.com

Top comments (2)

Collapse
 
mario_amazing_f0ce752f167 profile image
Mario Amazing

uninitialized constant ActiveRecord::Future

Collapse
 
sulmanweb profile image
Sulman Baig

Hey Thanks it will be coming in 8.1. But I have improved the code using load_async which is working in 8.0