作って学ぶRails 8入門|Shlink APIとつないでSPA風のURL短縮UIを実装する(実践編)

技術

はじめに

前編の記事では、Docker Compose を使って Rails 8 + MySQL の環境を整え、さらに importmap / Turbo / Stimulus / Tailwind を導入してモダンな開発基盤を作りました。

後編となる今回は、いよいよ Shlink API と連携して URL 短縮 UI を実装します。
ただし普通に「フォーム送信 → 結果ページへ遷移」といった従来型の動きではなく、Rails 7 以降で標準になった Turbo を活用し、ページ全体をリロードせずに部分更新する“ほぼSPA”風のUIを作っていきます。

最終的には:

  • トップページにURL入力フォーム
  • 送信すると Turbo で部分的に差し替え → 短縮URL表示
  • クリックでコピー(Stimulus)
  • 「もう一度短縮する」でフォームに戻る

という、軽快なUIをRailsで実現します。


Shlink API の準備

Shlink 側で API キーを取得しておきましょう。
Rails 側では .env に設定を置きます。

SHLINK_BASE_URL=https://shlink.example.com
SHLINK_API_KEY=your_api_key_here

.gitignore.env を追加しておくのを忘れずに。


サービスクラス

API呼び出し用のクライアントを用意します。

app/services/shlink_client.rb

require "faraday"
require "json"

class ShlinkClient
  def initialize
    @base_url = ENV.fetch("SHLINK_BASE_URL")
    @api_key  = ENV.fetch("SHLINK_API_KEY")
  end

  def shorten(long_url)
    conn = Faraday.new(url: @base_url) do |f|
      f.request :json
      f.response :json
    end

    res = conn.post("/rest/v3/short-urls") do |req|
      req.headers["X-Api-Key"] = @api_key
      req.body = { longUrl: long_url }
    end

    res.success? ? res.body["shortUrl"] : nil
  end
end

コントローラ(Turbo Stream 対応)

app/controllers/short_urls_controller.rb

class ShortUrlsController < ApplicationController
  def new
  end

  def create
    client = ShlinkClient.new
    @short_url = client.shorten(params[:long_url])

    respond_to do |format|
      if @short_url
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "result",
            partial: "short_urls/result",
            locals: { short_url: @short_url }
          )
        end
        format.html { redirect_to root_path, notice: "短縮しました" }
      else
        @error = "URLの短縮に失敗しました"
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "result",
            partial: "short_urls/form",
            locals: { error: @error, long_url: params[:long_url] }
          )
        end
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end
end

ビュー(Turbo Frame とパーシャル)

new.html.erb

<div class="max-w-xl mx-auto p-6">
  <h1 class="text-2xl font-bold mb-4">URL短縮</h1>

  <%= turbo_frame_tag "result" do %>
    <%= render "form", error: nil, long_url: nil %>
  <% end %>
</div>

_form.html.erb

<% if defined?(error) && error.present? %>
  <div class="mb-4 rounded bg-red-50 text-red-700 p-3"><%= error %></div>
<% end %>

<%= form_with url: short_urls_path, class: "space-y-4" do |f| %>
  <div>
    <%= f.label :long_url, "長いURLを入力してください", class: "block mb-1" %>
    <%= f.text_field :long_url,
          value: (defined?(long_url) ? long_url : nil),
          class: "border p-2 w-full rounded",
          placeholder: "https://example.com/very/long?query=params" %>
  </div>

  <div>
    <%= f.submit "短縮する", class: "bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" %>
  </div>
<% end %>

_result.html.erb

<div class="rounded border p-4 bg-white shadow-sm">
  <div class="text-sm text-gray-500 mb-1">短縮URL</div>
  <div class="flex items-center gap-2">
    <a href="<%= short_url %>" target="_blank" class="text-blue-600 underline break-all"><%= short_url %></a>

    <button
      type="button"
      class="text-xs px-2 py-1 rounded border hover:bg-gray-50"
      data-controller="clipboard"
      data-clipboard-text-value="<%= short_url %>"
      data-action="click->clipboard#copy">
      コピー
    </button>
  </div>

  <div class="mt-4">
    <%= link_to "別のURLを短縮する", "#", data: { action: "click->result#reset" }, class: "text-gray-600 underline" %>
  </div>
</div>

Stimulus(コピー機能とリセット)

clipboard_controller.js

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

export default class extends Controller {
  static values = { text: String }

  async copy() {
    try {
      await navigator.clipboard.writeText(this.textValue)
      this.element.textContent = "コピー済み"
      setTimeout(() => (this.element.textContent = "コピー"), 1200)
    } catch (e) {
      this.element.textContent = "コピー失敗"
    }
  }
}

result_controller.js

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

export default class extends Controller {
  async reset(event) {
    event.preventDefault()
    const response = await fetch("/", { headers: { "Turbo-Frame": "result" } })
    const html = await response.text()
    const frame = document.getElementById("result")
    frame.innerHTML = html
  }
}

Stimulus コントローラの生成コマンド:

docker compose run --rm web bin/rails generate stimulus clipboard
docker compose run --rm web bin/rails generate stimulus result

動作確認

docker compose up
  • http://localhost:3000 を開く
  • 長いURLを入力 → 「短縮する」クリック
  • Turbo Frame 内だけが差し替わり、短縮URLが表示される
  • 「コピー」でURLをクリップボードへ
  • 「別のURLを短縮する」でフォームに戻る

全体リロードが発生しないので、SPAのような体験になります。


まとめ

これで 前後編が完結しました。

  • 前編では Docker Compose × Rails 8 × MySQL 環境構築
  • 後編では Shlink API を呼び出して Turbo/Stimulus でSPA風のUIを実装

これにより、Rails 8 の入門として環境構築からモダンUIまでひと通り体験できます。

さらに発展させるなら:

  • 短縮URLの一覧と統計(クリック数表示)
  • QRコード生成
  • Tailwind で見た目を整える

などを追加すれば、本格的な Shlinkダッシュボード になります。

👉 完成版コードはこちら
https://github.com/enjoydarts/shlink-ui-rails

さらにこのプロジェクトは、その後 Claude Code を活用した「バイブコーディング」で機能を拡張し、認証・ダッシュボード・QRコード生成などを備えた本格的なアプリに進化しました。
その詳細は別記事でまとめますので、興味のある方はぜひご覧ください。

コメント

タイトルとURLをコピーしました