How to Build a Rails CSV Exporter with Hotwire

by | May 28, 2025 | Ruby On Rails | 0 comments

How to Build a Rails CSV Exporter with Hotwire

G’day Rails devs!

Ever needed to export your database records to CSV? Whether it’s for data analysis, client reports, or backup purposes, CSV export is a bread-and-butter feature in many Rails applications. Today, I’ll show you how to easily create a CSV export button for Rails 7 with Hotwire, no JavaScript needed!

Why CSV Export Matters

CSV (Comma-Separated Values) files are the Swiss Army knife of data exchange. They’re readable by Excel, Google Sheets, and virtually any data analysis tool. Plus, they’re human-readable, making them perfect for sharing data with non-technical stakeholders. In Rails, implementing CSV export is surprisingly straightforward, and with Hotwire, we can make it feel buttery smooth.

Setting Up the Foundation

First things first, let’s create a simple Rails app with a User model to demonstrate our CSV export functionality:

rails new csv_exporter_demo
cd csv_exporter_demo
rails generate model User name:string email:string role:string created_at:datetime
rails db:migrate

Let’s seed some sample data:

# db/seeds.rb
users = [
  { name: "Sarah Chen", email: "sarah@example.com", role: "admin" },
  { name: "Tom Wilson", email: "tom@example.com", role: "editor" },
  { name: "Emma Thompson", email: "emma@example.com", role: "viewer" },
  { name: "Jack Brown", email: "jack@example.com", role: "editor" },
  { name: "Lucy Davis", email: "lucy@example.com", role: "admin" }
]

users.each { |user| User.create!(user) }

Run rails db:seed to populate your database.

Creating the CSV Export Service

Instead of cluttering our controller with CSV logic, let’s create a clean service object:

# app/services/csv_export_service.rb
require 'csv'

class CsvExportService
  def initialize(records, attributes)
    @records = records
    @attributes = attributes
  end

  def generate
    CSV.generate(headers: true) do |csv|
      csv << headers

      @records.find_each do |record|
        csv << @attributes.map { |attr| record.public_send(attr) }
      end
    end
  end

  private

  def headers
    @attributes.map(&:to_s).map(&:humanize)
  end
end

This service is reusable for any model—just pass in the records and the attributes you want to export. Beauty!

Configuring the Model

Let’s tell our User model which fields should be exportable:

# app/models/user.rb
class User < ApplicationRecord
  def self.csv_attributes
    %w[id name email role created_at]
  end

  def self.to_csv
    CsvExportService.new(all, csv_attributes).generate
  end
end

This approach keeps our model lean while providing flexibility to customise which fields get exported.

Building the Controller

Now for the controller magic. We’ll use Rails’ respond_to to handle both HTML and CSV formats:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.all

    respond_to do |format|
      format.html
      format.csv do
        send_data @users.to_csv, 
                  filename: "users-#{Date.current}.csv",
                  type: 'text/csv',
                  disposition: 'attachment'
      end
    end
  end
end

The send_data method streams the CSV directly to the browser—no temporary files needed!

Creating the View with Hotwire

Here’s where the Turbo magic happens. Let’s create a clean interface with an export button:

<!-- app/views/users/index.html.erb -->
<div class="container">
  <div class="header">
    <h1>Users</h1>
    <%= link_to "Export to CSV", 
                users_path(format: :csv), 
                class: "btn btn-primary",
                data: { turbo: false } %>
  </div>

  <table class="table">
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Role</th>
        <th>Joined</th>
      </tr>
    </thead>
    <tbody>
      <% @users.each do |user| %>
        <tr>
          <td><%= user.name %></td>
          <td><%= user.email %></td>
          <td><%= user.role.capitalize %></td>
          <td><%= user.created_at.strftime("%B %d, %Y") %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>

Notice the data: { turbo: false } attribute? This tells Turbo to let the browser handle the download naturally, preventing any AJAX weirdness with file downloads.

Adding Some Style

Let’s make it look schmick with some basic CSS:

// app/assets/stylesheets/application.scss
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 2rem;
}

.btn {
  padding: 0.5rem 1rem;
  text-decoration: none;
  border-radius: 4px;
  transition: all 0.2s;
}

.btn-primary {
  background-color: #007bff;
  color: white;

  &:hover {
    background-color: #0056b3;
  }
}

.table {
  width: 100%;
  border-collapse: collapse;

  th, td {
    padding: 0.75rem;
    text-align: left;
    border-bottom: 1px solid #dee2e6;
  }

  th {
    background-color: #f8f9fa;
    font-weight: bold;
  }
}

Advanced Features

Filtering Exports

Want to export only certain records? Easy as:

# In your controller
def index
  @users = User.all
  @users = @users.where(role: params[:role]) if params[:role].present?

  respond_to do |format|
    format.html
    format.csv do
      send_data @users.to_csv, 
                filename: "users-#{params[:role] || 'all'}-#{Date.current}.csv"
    end
  end
end

Large Dataset Streaming

For massive datasets, streaming prevents memory issues:

# app/controllers/users_controller.rb
def index
  respond_to do |format|
    format.html { @users = User.all }
    format.csv do
      headers['Content-Type'] = 'text/csv'
      headers['Content-Disposition'] = "attachment; filename=\"users-#{Date.current}.csv\""
      headers['X-Accel-Buffering'] = 'no'

      self.response_body = csv_stream
    end
  end
end

private

def csv_stream
  Enumerator.new do |yielder|
    yielder << CSV.generate_line(User.csv_attributes.map(&:humanize))

    User.find_each(batch_size: 500) do |user|
      yielder << CSV.generate_line(User.csv_attributes.map { |attr| user.public_send(attr) })
    end
  end
end

This approach streams data in chunks, keeping memory usage low even with millions of records.

Progress Indicators with Turbo

For a more interactive experience, you can add a progress indicator:

<!-- Add to your index view -->
<div id="export-progress" style="display: none;">
  <div class="progress-bar">
    <div class="progress-fill"></div>
  </div>
  <p>Preparing your export...</p>
</div>

<%= link_to "Export to CSV", 
            users_path(format: :csv), 
            class: "btn btn-primary",
            data: { 
              turbo: false,
              controller: "csv-export",
              action: "click->csv-export#showProgress"
            } %>

And a simple Stimulus controller:

// app/javascript/controllers/csv_export_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showProgress() {
    document.getElementById('export-progress').style.display = 'block'

    setTimeout(() => {
      document.getElementById('export-progress').style.display = 'none'
    }, 3000)
  }
}

Testing Your CSV Exporter

Don’t forget to test! Here’s a simple RSpec example:

# spec/services/csv_export_service_spec.rb
require 'rails_helper'

RSpec.describe CsvExportService do
  let(:users) { create_list(:user, 3) }
  let(:service) { described_class.new(User.all, User.csv_attributes) }

  describe '#generate' do
    it 'includes headers' do
      csv = service.generate
      expect(csv).to include("Name,Email,Role")
    end

    it 'includes all records' do
      csv = service.generate
      users.each do |user|
        expect(csv).to include(user.email)
      end
    end
  end
end

Conclusion

There you have it—a clean, reusable Rails CSV exporter that plays nicely with Hotwire! No complex JavaScript, no third-party gems, just pure Rails goodness. The beauty of this approach is its simplicity and flexibility. You can easily extend it to any model in your application.

What data are you planning to export from your Rails apps? Have you tried streaming large datasets? Drop a comment below and share your CSV export war stories!

FAQ

Q: How do I export CSV files from a Rails application?
A: Use Rails’ built-in CSV library with the send_data method in your controller. Create a service object to generate CSV data from your models and stream it to the browser using send_data with the correct headers.

Q: Can I export large datasets without running out of memory?
A: Yes! Use streaming with find_each to process records in batches. Set response_body to an Enumerator that yields CSV lines incrementally, preventing memory overload.

Q: How do I make CSV exports work with Turbo?
A: Add data: { turbo: false } to your download links. This instructs Turbo to allow the browser to manage the file download instead of attempting to process it as a Turbo response.

Q: Can I customise which fields are exported to CSV?
A: Absolutely! Define a csv_attributes method in your model that returns an array of attribute names. Your CSV service can use this to determine which fields to include.

Q: How do I add custom headers to my CSV files?
A: In your CSV service, map your attribute names using humanize or create a custom mapping hash. The CSV.generate method accepts a headers option that automatically includes them as the first row.

Sources

  1. Rails CSV API Documentation
  2. Rails send_data Documentation
  3. Hotwire Turbo Documentation
  4. Ruby on Rails Guides – Active Record Query Interface

Let’s Keep the Conversation Going!

Did this tutorial help you level up your Rails skills? I’d love to hear about it!

Here’s what I’m curious about:

  • What’s the most challenging part of implementing this in your own projects?
  • Have you found any clever variations or improvements to this approach?
  • What other Rails topics are keeping you up at night?

Drop a comment below and share your experience. Whether you’ve discovered a brilliant shortcut or you’re stuck on something tricky, the Rails community thrives on shared knowledge.

Found this helpful? Share it with your fellow developers! Sometimes the best way to solidify your understanding is to explain it to someone else.

And if you’ve got a different approach that works better for your use case, I’m all ears! Rails offers various ways to solve problems, and your approach might be just what another developer needs.

Stay curious, keep coding, and remember – even DHH started as a beginner once!

Need a Rails Expert? Let's Chat!

If you’re looking for someone to help bring your Rails project to life, I’m available for consulting work. If you need assistance with creating a new application, fixing an old codebase, or adding complex features, I’d love to help.

From quick code reviews to long-term partnerships, I work with startups, established companies, and everyone in between. I am based in Australia but happy to work with teams globally.

Ready to make your Rails app brilliant? Book A Consultation or connect on LinkedIn. Let’s discuss how I can help turn your ideas into reality.


P.S. Not quite ready for a full project? I also offer one-off consultation calls to help you get unstuck or validate your approach. Sometimes a fresh pair of experienced eyes is all you need!

Follow Us

Subscribe

About The Author

Jeremy is the founder of heyjeremy.com, a developer who specialises in Ruby on Rails, WordPress, and Shopify's Liquid framework. He is based in the Gold Coast hinterland, Australia, where he uses his technical skills to create innovative web solutions. When not coding, Jeremy enjoys exploring with his rescue dog Max, a Kelpie-Husky mix, trying new technologies, and looking for authentic Mexican food.

More Reading

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *

Share This

Share this post with your friends!