Building Real-Time MTG Card Search with Rails 7 and Hotwire


Rails Hotwire Turbo Stimulus API
tutorial rails hotwire search api

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

MTG Card Search - Initial State

Why Hotwire? (And Why I’m Excited About It)

Before we dive in, let me tell you why I fell in love with Hotwire:

  1. No JavaScript Fatigue - I don’t need to learn React, Vue, or whatever new framework came out this week
  2. Server-Side Rendering - Everything happens on the server, which I’m already comfortable with
  3. Progressive Enhancement - It works even if JavaScript is disabled
  4. 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:

  1. Initial state - The welcome message and blank search box
  2. Loading state - Smooth loading animation while the API request happens
  3. Results - Real card data with actual images and prices
  4. Error handling - Graceful error messages if something goes wrong

Real-Time Search in Action

Here’s a GIF showing the real-time search functionality:

MTG Search Real-Time Demo

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


Happy coding! If you build something cool with Hotwire, I’d love to see it! 🚀