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
- Rails CSV API Documentation
- Rails send_data Documentation
- Hotwire Turbo Documentation
- 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!
0 Comments