Building Real-Time MTG Card Search with Rails 7 and Hotwire
Building Real-Time MTG Card Search with Rails 7 and Hotwire
Hey!
So I recently discovered Hotwire and honestly, my mind was blown. Like, you can build real-time, interactive web apps WITHOUT writing tons of JavaScript? Sign me up!
I decided to build a Magic: The Gathering card search app to learn Hotwire, and let me tell you - it was way easier than I expected. If you’re like me and sometimes feel overwhelmed by all the JavaScript frameworks out there, this tutorial is for you.
What We’re Building
We’re going to create an MTG card search application that:
- Searches cards in real-time as you type
- Shows loading states (because users love feedback!)
- Displays beautiful card results with real pricing data
- Works without writing complex JavaScript
- Integrates with the Scryfall API for real MTG data
Why Hotwire? (And Why I’m Excited About It)
Before we dive in, let me tell you why I fell in love with Hotwire:
- No JavaScript Fatigue - I don’t need to learn React, Vue, or whatever new framework came out this week
- Server-Side Rendering - Everything happens on the server, which I’m already comfortable with
- Progressive Enhancement - It works even if JavaScript is disabled
- Rails Integration - It’s built into Rails 7, so no extra setup headaches
Setting Up Our Rails App
First, let’s create a new Rails 7 application. I’m assuming you have Rails 7 installed (if not, gem install rails
should do the trick).
rails new mtg_search
cd mtg_search
Since we’re using Rails 7, Hotwire comes pre-installed! 🎉
Let’s add the HTTP client gem for API requests and generate our controller:
# Gemfile
gem 'httparty'
bundle install
rails generate controller Cards index
Understanding Hotwire Components
Before we start coding, let me break down the Hotwire stack (this confused me at first):
- Turbo Drive - Makes page navigation faster (like Turbolinks but better)
- Turbo Frames - Updates parts of the page without full reload
- Turbo Streams - Real-time updates over WebSockets
- Stimulus - Minimal JavaScript for when you really need it
For our search, we’ll mainly use Turbo Frames. Think of them as “containers” that can update independently.
Building the Search Interface
Let’s start with our basic search form. Here’s what I learned: the key is wrapping everything in turbo_frame_tag
.
Update app/views/cards/index.html.erb
:
<div class="container mx-auto px-4 py-8">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-4">MTG Card Search</h1>
<p class="text-gray-600">Search for Magic: The Gathering cards and explore their different prints</p>
</div>
<%= form_with url: cards_path, method: :get, local: true,
data: { turbo_frame: "search_results", turbo_action: "advance" },
class: "mb-8" do |f| %>
<div class="max-w-2xl mx-auto">
<%= f.text_field :q,
value: params[:q],
placeholder: "Start typing a card name...",
class: "w-full px-4 py-3 text-lg border-2 border-blue-300 rounded-lg focus:border-blue-500 focus:outline-none",
data: {
action: "input->search#perform",
search_target: "input"
} %>
</div>
<% end %>
<%= turbo_frame_tag "search_results" do %>
<div class="text-center py-16">
<div class="text-6xl mb-4">🃏</div>
<h2 class="text-xl font-semibold text-gray-700 mb-2">Search for a card to see its different prints</h2>
<p class="text-gray-500">Type at least 2 characters to start searching</p>
</div>
<% end %>
</div>
Wait, what’s that data: { turbo_frame: "search_results" }
doing? This tells Turbo to update only the content inside the turbo_frame_tag "search_results"
instead of the whole page. Pretty neat, right?
Creating the Scryfall API Service
Now let’s create a service class to handle our API calls to Scryfall (the best MTG card database). I learned that keeping API logic separate makes everything cleaner:
# app/services/scryfall_service.rb
class ScryfallService
include HTTParty
base_uri 'https://api.scryfall.com'
def self.search_cards(query, page: 1)
cache_key = "scryfall_search_#{query.downcase.gsub(/\s+/, '_')}_#{page}"
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
perform_search(query, page)
end
end
private
def self.perform_search(query, page)
options = {
query: {
q: query,
page: page,
format: 'json'
},
timeout: 10
}
response = get('/cards/search', options)
case response.code
when 200
{ success: true, data: response.parsed_response }
when 404
{ success: true, data: { 'data' => [] } } # No results found
else
{ success: false, error: "API returned #{response.code}" }
end
rescue Net::TimeoutError, Net::OpenTimeout
{ success: false, error: "Request timed out - please try again" }
rescue StandardError => e
Rails.logger.error "Scryfall API error: #{e.message}"
{ success: false, error: "Unable to search cards right now" }
end
end
Adding the Controller Logic
Now let’s make our controller handle real API requests. This is where I had to think about error handling and user experience:
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
def index
@query = params[:q]
@cards = []
@error = nil
if @query.present? && @query.length >= 2
result = ScryfallService.search_cards(@query)
if result[:success]
@cards = format_cards(result[:data]['data'] || [])
else
@error = result[:error]
end
end
if turbo_frame_request?
render partial: 'search_results'
end
end
private
def format_cards(raw_cards)
raw_cards.map do |card|
{
id: card['id'],
name: card['name'],
set_name: card['set_name'],
set_code: card['set'],
image_url: card.dig('image_uris', 'normal') || card.dig('card_faces', 0, 'image_uris', 'normal'),
prices: {
usd: card.dig('prices', 'usd'),
eur: card.dig('prices', 'eur'),
tix: card.dig('prices', 'tix')
},
rarity: card['rarity'],
released_at: card['released_at'],
scryfall_uri: card['scryfall_uri']
}
end
end
end
Creating the Search Results Partial
Let’s create our search results template that handles real data and errors:
<!-- app/views/cards/_search_results.html.erb -->
<%= turbo_frame_tag "search_results" do %>
<% if @query.present? && @query.length >= 2 %>
<% if @error %>
<!-- Error State -->
<div class="max-w-2xl mx-auto text-center py-8">
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<div class="text-red-600 text-lg font-semibold mb-2">Oops! Something went wrong</div>
<div class="text-red-700"><%= @error %></div>
<button onclick="location.reload()" class="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Try Again
</button>
</div>
</div>
<% elsif @cards.any? %>
<!-- Results -->
<div class="max-w-7xl mx-auto">
<div class="text-center mb-6">
<h2 class="text-2xl font-bold text-gray-800"><%= @query %></h2>
<p class="text-gray-600"><%= pluralize(@cards.length, 'print') %> found</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
<% @cards.each do |card| %>
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-200">
<!-- Card Image -->
<div class="aspect-w-3 aspect-h-4">
<img src="<%= card[:image_url] %>"
alt="<%= card[:name] %>"
class="w-full h-64 object-cover"
loading="lazy">
</div>
<!-- Card Info -->
<div class="p-4">
<h3 class="font-bold text-lg mb-1 line-clamp-2"><%= card[:name] %></h3>
<p class="text-sm text-gray-600 mb-1"><%= card[:set_name] %></p>
<p class="text-xs text-gray-500 mb-3">
<span class="uppercase font-mono"><%= card[:set_code] %></span> •
<span class="capitalize"><%= card[:rarity] %></span>
</p>
<!-- Pricing -->
<div class="space-y-2">
<% if card[:prices][:usd] %>
<div class="bg-green-50 rounded p-2">
<div class="text-xs text-green-700">💰 Current Market Price</div>
<div class="text-lg font-bold text-green-800">$<%= card[:prices][:usd] %></div>
</div>
<% elsif card[:prices][:eur] %>
<div class="bg-blue-50 rounded p-2">
<div class="text-xs text-blue-700">💰 Current Market Price</div>
<div class="text-lg font-bold text-blue-800">€<%= card[:prices][:eur] %></div>
</div>
<% elsif card[:prices][:tix] %>
<div class="bg-orange-50 rounded p-2">
<div class="text-xs text-orange-700">💰 MTGO Price</div>
<div class="text-lg font-bold text-orange-800"><%= card[:prices][:tix] %> TIX</div>
</div>
<% else %>
<div class="bg-gray-50 rounded p-2">
<div class="text-xs text-gray-600">💰 Current Market Price</div>
<div class="text-sm text-gray-700">Price not available</div>
</div>
<% end %>
<!-- View on Scryfall Link -->
<a href="<%= card[:scryfall_uri] %>"
target="_blank"
class="block text-center text-sm text-blue-600 hover:text-blue-800 mt-2">
View on Scryfall →
</a>
</div>
</div>
<!-- Release Date -->
<div class="px-4 pb-3 text-xs text-gray-500">
Released: <%= Date.parse(card[:released_at]).strftime("%B %Y") %>
</div>
</div>
<% end %>
</div>
</div>
<% else %>
<!-- No Results -->
<div class="text-center py-8">
<div class="text-6xl mb-4">🔍</div>
<div class="text-lg text-gray-600">No cards found for "<%= @query %>"</div>
<p class="text-gray-500">Try a different search term or check your spelling</p>
</div>
<% end %>
<% else %>
<!-- Initial State -->
<div class="text-center py-16">
<div class="text-6xl mb-4">🃏</div>
<h2 class="text-xl font-semibold text-gray-700 mb-2">Search for a card to see its different prints</h2>
<p class="text-gray-500">Type at least 2 characters to start searching</p>
</div>
<% end %>
<% end %>
Adding Real-Time Search with Stimulus
Now here’s where the magic happens! We need just a tiny bit of JavaScript to make the search happen as we type. Don’t worry - it’s super simple.
Create app/javascript/controllers/search_controller.js
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
static values = {
minLength: { type: Number, default: 2 },
debounceDelay: { type: Number, default: 300 }
}
connect() {
console.log("Search controller connected!")
this.abortController = null
}
disconnect() {
if (this.abortController) {
this.abortController.abort()
}
}
perform() {
// Cancel any pending request
if (this.abortController) {
this.abortController.abort()
}
// Clear any existing timeout
clearTimeout(this.timeout)
// Set a new timeout to avoid too many requests
this.timeout = setTimeout(() => {
const query = this.inputTarget.value.trim()
if (query.length >= this.minLengthValue) {
this.search(query)
} else if (query.length === 0) {
this.reset()
}
}, this.debounceDelayValue)
}
search(query) {
// Show loading state
this.showLoading()
// Create new abort controller for this request
this.abortController = new AbortController()
// Add the abort signal to the form submission
const form = this.inputTarget.form
const formData = new FormData(form)
fetch(form.action + '?' + new URLSearchParams(formData), {
method: 'GET',
headers: {
'Accept': 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml',
'Turbo-Frame': 'search_results'
},
signal: this.abortController.signal
})
.then(response => response.text())
.then(html => {
document.getElementById('search_results').innerHTML = html
})
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Search failed:', error)
this.showError('Search failed. Please try again.')
}
})
}
reset() {
const form = this.inputTarget.form
form.requestSubmit()
}
showLoading() {
const frame = document.getElementById('search_results')
if (frame) {
frame.innerHTML = `
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
<div class="text-lg text-gray-600">Loading card prints...</div>
<div class="text-sm text-gray-500 mt-2">Searching Scryfall database...</div>
</div>
`
}
}
showError(message) {
const frame = document.getElementById('search_results')
if (frame) {
frame.innerHTML = `
<div class="max-w-2xl mx-auto text-center py-8">
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<div class="text-red-600 text-lg font-semibold mb-2">Search Error</div>
<div class="text-red-700">${message}</div>
</div>
</div>
`
}
}
}
Adding Routes
Don’t forget to add the route to config/routes.rb
:
Rails.application.routes.draw do
root 'cards#index'
resources :cards, only: [:index]
end
Testing Our App
Now let’s test everything! Start your server and try searching for “Black Lotus”:
rails server
Navigate to http://localhost:3000
and you should see:
- Initial state - The welcome message and blank search box
- Loading state - Smooth loading animation while the API request happens
- Results - Real card data with actual images and prices
- Error handling - Graceful error messages if something goes wrong
Real-Time Search in Action
Here’s a GIF showing the real-time search functionality:
What We’ve Accomplished (And What I Learned)
Honestly, I’m still amazed at how little code this took! Here’s what we built:
✅ Real-time search - Updates as you type
✅ API integration - Real data from Scryfall
✅ Loading states - Users get immediate feedback
✅ Error handling - Graceful failure management
✅ Caching - Better performance with Rails cache
✅ No page refreshes - Everything happens smoothly
✅ Minimal JavaScript - Just a tiny Stimulus controller
✅ Server-side rendering - All the logic stays in Rails
Final Thoughts
Building this MTG search app taught me that Hotwire really delivers on its promise. I was able to create a fast, interactive application without drowning in JavaScript complexity.
The best part? Everything we built is progressively enhanced. If JavaScript fails, the basic search still works. If the API is down, users get helpful error messages. That’s the kind of robustness I want in my applications.
Resources That Helped Me
- Hotwire Handbook - The official docs are actually really good
- Scryfall API Documentation - Excellent API with great docs
- Stimulus Handbook - For when you need that tiny bit of JavaScript
Happy coding! If you build something cool with Hotwire, I’d love to see it! 🚀