Rails apps have been doing CSV import the same way since 2009: a <%= file_field_tag :file %>, a controller action that calls CSV.parse, and a model that creates records one by one. It works — until your customers start uploading 50K rows from an Excel file with merged cells and European date formats.
This guide shows the modern alternative: a five-minute integration of Rowslint, a client-side CSV and Excel importer, with a Rails app. AI column matching, in-browser validation, multi-format parsing, and a clean insert_all path on the server.
What is Rowslint
Rowslint is an embedded CSV and Excel importer for web apps. From a Rails ERB view (with or without Stimulus), you call launchRowslint(), your users get a polished import flow, and you receive a JSON array of validated, typed rows in a Rails controller.
The importer parses files entirely in the browser. Rails doesn’t need to handle file uploads, parse CSVs, or worry about Excel binary formats — only the cleaned rows arrive.
Step 1: Create a Rowslint account and template
Sign up for the free tier. In the dashboard:
- Create a template (e.g.
customers_v1). - Add columns matching your
customerstable. - Set types and validators.
- Save and copy the template key + your organization API key.
Step 2: Pin Rowslint in Importmaps
Modern Rails (7+) ships with Importmaps. Pin Rowslint:
bin/importmap pin @rowslint/importer-js
This adds the package to config/importmap.rb and downloads it to vendor/javascript/.
Expose your API key from Rails to the frontend via a <meta> tag in your layout:
<%# app/views/layouts/application.html.erb %>
<meta name="rowslint-api-key" content="<%= Rails.application.credentials.rowslint_api_key %>">
Store the key in Rails.application.credentials:
EDITOR=nvim bin/rails credentials:edit
# Add: rowslint_api_key: org_pk_live_xxxxxxxxxxxxx
Step 3: Build a Stimulus controller
app/javascript/controllers/customer_import_controller.js:
import { Controller } from '@hotwired/stimulus';
import { launchRowslint } from '@rowslint/importer-js';
export default class extends Controller {
static values = { templateKey: String, endpoint: String };
open() {
const apiKey = document.querySelector('meta[name="rowslint-api-key"]').content;
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
launchRowslint({
apiKey,
config: { templateKey: this.templateKeyValue },
onImport: async (result) => {
if (result.status !== 'success') return;
const response = await fetch(this.endpointValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/vnd.turbo-stream.html',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ rows: result.data }),
});
if (response.ok) {
const html = await response.text();
Turbo.renderStreamMessage(html);
}
},
});
}
}
Register it (Rails auto-registers if you used the Stimulus generator):
bin/rails generate stimulus customer_import
Step 4: Wire the controller to a button in ERB
app/views/customers/index.html.erb:
<header>
<h1>Customers</h1>
<%= button_tag 'Import customers',
data: {
controller: 'customer-import',
action: 'click->customer-import#open',
customer_import_template_key_value: 'customers_v1',
customer_import_endpoint_value: bulk_import_customers_path
} %>
</header>
<turbo-frame id="customers-list">
<%= render @customers %>
</turbo-frame>
Step 5: Receive rows in a Rails controller
config/routes.rb:
resources :customers do
collection do
post :bulk_import
end
end
app/controllers/customers_controller.rb:
class CustomersController < ApplicationController
before_action :authenticate_user!
def bulk_import
rows = bulk_import_params[:rows]
return head :unprocessable_entity if rows.blank? || rows.size > 50_000
inserted = 0
Customer.transaction do
rows.each_slice(500) do |batch|
records = batch.map do |r|
{
email: r[:email],
name: r[:name],
plan: r[:plan],
created_at: Time.parse(r[:created_at]),
updated_at: Time.current,
}
end
Customer.insert_all(records, unique_by: :email)
inserted += batch.size
end
end
@customers = Customer.recent.limit(50)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
'customers-list',
partial: 'customers/list',
locals: { customers: @customers, last_imported: inserted }
)
end
format.json { render json: { inserted: } }
end
end
private
def bulk_import_params
params.permit(rows: %i[email name plan created_at])
end
end
Three things this controller gets right:
authenticate_user!+ Rails CSRF middleware secure the endpoint by default.insert_allwithunique_by: :emailis idempotent — running the same import twice doesn’t crash on duplicates.- Turbo Stream response updates the customer list reactively without a full page reload.
Step 6: Add async validation against Active Record
Configure async validators in your Rowslint template (dashboard) pointing at a Rails controller:
# config/routes.rb
resources :validations, only: [] do
collection do
post :email
end
end
# app/controllers/validations_controller.rb
class ValidationsController < ApplicationController
before_action :authenticate_user!
def email
email = params[:value]
exists = Customer.where(email: email).exists?
render json: {
valid: !exists,
message: exists ? 'A customer with this email already exists' : nil,
}
end
end
Users see inline errors as they map columns. They never re-upload a file.
Step 7: Render the imported list as a partial
app/views/customers/_list.html.erb:
<% if local_assigns[:last_imported] %>
<div class="flash flash-success" role="status">
Imported <%= last_imported %> customers.
</div>
<% end %>
<table>
<% customers.each do |customer| %>
<tr>
<td><%= customer.name %></td>
<td><%= customer.email %></td>
<td><%= customer.plan %></td>
</tr>
<% end %>
</table>
The Turbo Stream from the controller swaps this in with no JS-flash and no full reload — peak Hotwire DX.
Why Rails teams pick Rowslint
- No
roo/creekdependency. Excel parsing in Ruby pulls in 30+ MB of dependencies, has known XXE vulnerabilities, and is slow. Rowslint parses Excel in the browser sandbox. - No background job for 95% of imports. A 50K-row CSV is parsed and validated in the user’s browser before it hits Rails. Sidekiq isn’t needed.
- No file upload churn. No
ActiveStorageblobs, notempfilecleanup, nomultipart/form-datahandling. Just JSON.
Compared to other Rails CSV tools
| Feature | Rowslint | roo + Active Record | activerecord-import | smarter_csv |
|---|---|---|---|---|
| Drop-in customer-facing UI | ✓ | ✗ | ✗ | ✗ |
| AI column matching | ✓ | ✗ | ✗ | ✗ |
| Excel (XLSX) support | ✓ | ✓ (heavy) | n/a | ✗ |
| Async validation during mapping | ✓ | ✗ | ✗ | ✗ |
| Server CPU per import | low | high | medium | medium |
| Setup time | < 5 min | ~1 week | ~2 days | ~3 days |
activerecord-import and smarter_csv are still great for rake tasks and internal data pipelines. Rowslint is the right tool for customer-facing import.
Production-ready checklist
-
authenticate_user!and CSRF on the bulk endpoint -
insert_all/upsert_allfor fast, idempotent inserts -
each_slice(500)to keep batches under DB limits - Wrap in
transactionfor atomicity - Turbo Stream response for reactive updates
- Async validators wired for uniqueness checks
- Skip Active Record callbacks on bulk paths if you don’t need them
Conclusion
You don’t need to roll your own Rails CSV importer in 2026. With Rowslint, you get AI column matching, Excel support, async validators, and a polished mapping UI — wired into your Rails app via Importmaps and Stimulus in five minutes.
Start with the free tier and ship a modern import flow today. See the JavaScript SDK reference for the full API.
Frequently asked questions
- What is the best way to import CSV files in Ruby on Rails?
- For customer-facing import flows, the most maintainable approach is a client-side importer like Rowslint that hands cleaned, validated rows to a Rails controller. This avoids file upload endpoints, server-side `CSV.parse`, and the operational burden of background jobs for what is fundamentally a UI problem. For internal rake tasks, Ruby's standard library `CSV` and `roo` for Excel remain solid choices.
- How do I integrate Rowslint with Rails Importmaps?
- Add `@rowslint/importer-js` as a pinned import in `config/importmap.rb`, then `import` it from a Stimulus controller. Wire the controller to a button in your ERB view, and POST validated rows to a Rails controller via `Turbo.fetch` or `fetch` with the CSRF token. Setup takes five minutes.
- Does Rowslint work with Hotwire and Turbo?
- Yes. After import, return a Turbo Stream response from your controller to update the page reactively — no full reload required. The article shows the full pattern with Stimulus, Turbo Streams, and `insert_all` for bulk persistence.
- How do I bulk insert imported rows in Rails?
- Use `Customer.insert_all(rows, returning: %i[id email])` for fast inserts that bypass Active Record callbacks, or `Customer.upsert_all` for idempotent inserts on conflict. Always batch in chunks of 500–1000 rows to avoid `prepared_statements` issues and hit the `limit_for_insert_all`.
- Can I validate imported rows against my Rails models?
- Yes. Configure async validators in your Rowslint template that POST to a Rails controller returning `{valid: bool, message: string}`. Users see inline errors during column mapping for uniqueness checks, foreign keys, and any custom Active Record validation.
- Should I use Rowslint or a Sidekiq-based CSV import?
- Different jobs. Rowslint is for synchronous, customer-facing UIs where the user wants immediate feedback. Sidekiq-based imports make sense for very large files (>2 GB) or complex post-import processing (image generation, email blasts). Many Rails apps use Rowslint for the upload UX and Sidekiq for the post-import async work.