Rails アプリケーションで Vue 3 を使うときの構成の選び方、セットアップ、設計・実装の指針をまとめたものです。


目次

  1. まず構成パターンを選ぶ
  2. ビルドツール:Vite を使う
  3. ディレクトリ構成
  4. パターン別の実装
  5. Vue 側のコーディング指針
  6. Rails との連携で気をつけること
  7. テスト
  8. 開発体験・品質
  9. デプロイ・本番
  10. よくあるアンチパターン
  11. まとめ

1. まず構成パターンを選ぶ

Rails と Vue の組み合わせ方は大きく 3 つあります。最初にどれを採用するかで、その後の設計がほぼ決まります。

パターン概要向いているケース
A. アイランド型(部分導入)ERB で画面を描画し、一部の要素だけ Vue コンポーネントをマウントする既存の Rails アプリに少しずつ導入したい/インタラクティブな箇所が限定的
B. Inertia.jsRails のルーティング・コントローラーをそのまま使い、ビュー層を Vue に置き換えるRails の開発体験を保ったまま SPA 的な UI にしたい(多くの新規案件で第一候補)
C. 完全分離(API + SPA)Rails は API 専用(--api)、Vue は別リポジトリ/別デプロイの SPAモバイルアプリ等と API を共有する/フロントとバックでチームが分かれている

判断の目安

  • 迷ったら B(Inertia)。API 設計・クライアント側ルーティング・状態の二重管理が不要になり、認証やバリデーションも Rails の流儀のまま扱えます。
  • 既存アプリに足すなら A。ページ全体を作り替えずに済みます。
  • C は API を他クライアントと共有する明確な理由があるときだけ。認証(CORS、トークン管理)、ルーティング、型共有などのコストが一気に増えます。
  • Hotwire(Turbo / Stimulus)で十分な画面まで Vue にしないこと。フォームや一覧などのシンプルな画面は Rails 標準の方が保守コストが低いことが多いです。

2. ビルドツール:Vite を使う

Webpacker は開発終了済みです。Rails で Vue を使うなら Vite(vite_rails gem) が事実上の標準です。

# Gemfile
gem "vite_rails"
bundle install
bundle exec vite install
npm install vue @vitejs/plugin-vue
// vite.config.ts
import { defineConfig } from "vite"
import RubyPlugin from "vite-plugin-ruby"
import vue from "@vitejs/plugin-vue"
 
export default defineConfig({
  plugins: [RubyPlugin(), vue()],
})
<%# app/views/layouts/application.html.erb %>
<%= vite_client_tag %>
<%= vite_javascript_tag "application" %>

ポイント

  • importmap-rails は Vue の SFC(.vue)をビルドできないため、Vue を本格的に使うなら Vite を選ぶ。
  • jsbundling-rails(esbuild)でも可能だが、HMR や Vue プラグインのエコシステムを考えると Vite の方が快適。
  • 開発時は bin/dev(Procfile.dev)で Rails と Vite dev server を同時起動する。

3. ディレクトリ構成

app/frontend 以下にフロントエンドのコードを集約します(vite_rails のデフォルト)。

app/frontend/
├── entrypoints/        # Vite のエントリーポイント(ここに置いたものだけがバンドルされる)
│   └── application.ts
├── pages/              # Inertia のページコンポーネント(パターンB)
│   └── Users/
│       ├── Index.vue
│       └── Show.vue
├── components/         # 再利用可能な UI コンポーネント
│   ├── ui/             # ボタン・モーダルなど汎用部品
│   └── users/          # ドメイン別の部品
├── composables/        # useXxx 形式のロジック
├── stores/             # Pinia ストア(必要な場合のみ)
├── api/                # API クライアント(パターンA/C)
├── types/              # 型定義
└── layouts/            # 共通レイアウト

ポイント

  • entrypoints/ にはエントリーファイルだけを置く。部品を置くと不要なバンドルが生成される。
  • Rails のリソース名(users)と Vue のディレクトリ名を揃えると対応関係が追いやすい。
  • @/ エイリアスを app/frontend に向けておく(vite-plugin-ruby はデフォルトで ~/ と @/ を設定済み)。

4. パターン別の実装

A. アイランド型

data-* 属性で Rails からデータを渡し、マウントポイントごとに Vue アプリを作成します。

<%# app/views/users/show.html.erb %>
<div
  data-vue-component="UserProfile"
  data-props="<%= { user: @user.as_json(only: [:id, :name, :email]) }.to_json %>">
</div>
// app/frontend/entrypoints/application.ts
import { createApp, defineAsyncComponent } from "vue"
 
const components = import.meta.glob("../components/**/*.vue")
 
document.querySelectorAll<HTMLElement>("[data-vue-component]").forEach((el) => {
  const name = el.dataset.vueComponent!
  const path = Object.keys(components).find((p) => p.endsWith(`/${name}.vue`))
  if (!path) return
 
  const props = JSON.parse(el.dataset.props || "{}")
  createApp(defineAsyncComponent(components[path] as any), props).mount(el)
})
  • import.meta.glob で遅延読み込みにし、そのページで使うコンポーネントだけ読み込む。
  • Turbo を併用する場合は turbo:load でマウント、turbo:before-render で app.unmount() してメモリリークを防ぐ。
  • props は to_json 経由で渡す。html_safe や raw で直接埋め込まない(XSS の原因)。

B. Inertia.js

# Gemfile
gem "inertia_rails"
bin/rails generate inertia:install
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    render inertia: "Users/Index", props: {
      users: User.order(:name).map { |u| UserSerializer.new(u).as_json }
    }
  end
 
  def create
    user = User.new(user_params)
    if user.save
      redirect_to users_path, notice: "作成しました"
    else
      redirect_to new_user_path, inertia: { errors: user.errors }
    end
  end
end
<!-- app/frontend/pages/Users/Index.vue -->
<script setup lang="ts">
import { Link } from "@inertiajs/vue3"
import type { User } from "@/types"
 
defineProps<{ users: User[] }>()
</script>
 
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <Link :href="`/users/${user.id}`">{{ user.name }}</Link>
    </li>
  </ul>
</template>
  • 共通データ(ログインユーザー、flash など)は inertia_share でまとめて渡す。
  • フォームは useForm を使うと送信中状態・バリデーションエラー表示が簡単に書ける。
  • props は必要なカラムだけに絞る。モデルをそのまま渡すとパスワードダイジェスト等が漏れる危険がある。
  • 重い props は遅延評価(lazy / optional props)にして、必要なときだけ取得する。

C. 完全分離(API + SPA)

  • Rails は rails new --api で作成し、rack-cors で許可オリジンを明示的に設定する。
  • 認証は同一ドメイン(サブドメイン含む)なら Cookie ベースのセッションが最も安全。別ドメインでトークンを使う場合も localStorage への保存は避け、HttpOnly Cookie を検討する。
  • API はバージョニング(/api/v1/...)し、レスポンス形式を統一する。
  • OpenAPI(rswag など)でスキーマを定義し、TypeScript の型を自動生成すると、フロントとバックの不整合を防げる。

5. Vue 側のコーディング指針

Composition API + <script setup> + TypeScript を標準にする

<script setup lang="ts">
import { ref, computed } from "vue"
 
const props = defineProps<{ initialCount: number }>()
const emit = defineEmits<{ change: [value: number] }>()
 
const count = ref(props.initialCount)
const doubled = computed(() => count.value * 2)
 
function increment() {
  count.value++
  emit("change", count.value)
}
</script>
  • Options API は新規コードでは使わない。
  • 型は defineProps<T>() のジェネリクス記法で定義する。

ロジックは composable に切り出す

// app/frontend/composables/useFetch.ts
import { ref } from "vue"
 
export function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
 
  async function execute() {
    loading.value = true
    try {
      const res = await fetch(url, { headers: { Accept: "application/json" } })
      if (!res.ok) throw new Error(res.statusText)
      data.value = await res.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }
 
  return { data, error, loading, execute }
}

状態管理

  • Inertia を使う場合、サーバーの状態はサーバー(props)が持つ。Pinia に複製しない。
  • Pinia はモーダルの開閉、UI 設定、複数コンポーネントをまたぐ一時的なクライアント状態に限定する。
  • 「とりあえず全部ストアに入れる」は避け、まずはコンポーネントのローカル状態と props/emit で済ませる。

6. Rails との連携で気をつけること

CSRF トークン

Rails の CSRF 保護を無効化せず、リクエストにトークンを付与します(Inertia は自動で対応)。

// app/frontend/api/client.ts
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content
 
export async function apiPost<T>(url: string, body: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      "X-CSRF-Token": token ?? "",
    },
    body: JSON.stringify(body),
  })
  if (!res.ok) throw new Error(`${res.status}`)
  return res.json()
}

レイアウトに <%= csrf_meta_tags %> があることを確認してください。

シリアライズ

  • as_json の場当たり的な利用ではなく、シリアライザー(alba、jbuilder、blueprinter など)でレスポンス形を一元管理する。
  • キー名の規約を統一する。Ruby の snake_case のまま渡すか、camelCase に変換するかをプロジェクトで決め、混在させない。

ルーティング

  • フロントでパスを文字列直書きすると、Rails 側の変更に追従できなくなる。js-routes gem で Rails のルートヘルパーを TypeScript から使えるようにすると安全。

バリデーション

  • バリデーションの正はサーバー(モデル)。フロントのバリデーションは UX のための補助と考える。
  • エラーは errors.messages の形式で返し、フロント側で項目ごとに表示する共通コンポーネントを作る。

N+1 対策

  • Vue に渡すデータを作る段階で includes / preload を使う。props に関連データを詰め込むと N+1 が起きやすい。bullet gem で検出する。

7. テスト

対象ツール方針
Vue コンポーネントVitest + Vue Test Utilsprops と表示、イベント発火を中心に検証
composableVitest純粋なロジックとして単体テスト
Rails コントローラーRSpec(request spec)Inertia の場合は inertia_rails のテストヘルパーで描画コンポーネントと props を検証
E2ECapybara + Cuprite / Playwright主要な画面遷移のみに絞る
  • コンポーネントの内部実装(ref の値など)ではなく、ユーザーから見える振る舞いをテストする。
  • E2E は遅く壊れやすいので、重要なユーザーフローに限定する。

8. 開発体験・品質

  • ESLint(eslint-plugin-vue)+ Prettier を導入し、CI で実行する。
  • 型チェックは vue-tsc --noEmit を CI に組み込む(Vite のビルドは型エラーで失敗しないため)。
  • Ruby 側は RuboCop、フロント側は ESLint と、それぞれの言語の標準ツールに任せる。
  • bin/dev 一発で開発環境が立ち上がる状態を保つ。

9. デプロイ・本番

  • assets:precompile 時に Vite のビルドが走る(vite_rails が自動でフックする)。CI/本番環境に Node.js を用意すること。
  • Docker の場合はマルチステージビルドにし、最終イメージには node_modules を含めない。
  • ビルド成果物にはハッシュ付きファイル名が付くので、CDN で長期キャッシュを設定してよい。
  • SEO や初期表示速度が重要なページは、Inertia の SSR か、その画面だけ ERB で描画することを検討する。

10. よくあるアンチパターン

  • ❌ すべての画面を Vue にする → シンプルな画面は ERB / Hotwire の方が速く作れて保守しやすい
  • ❌ Inertia を使っているのに別途 API を作り、Pinia にサーバーデータを複製する
  • ❌ モデルをそのまま to_json で渡す → 機密カラムの漏洩
  • ❌ skip_forgery_protection で CSRF 保護を無効化する
  • ❌ アクセストークンを localStorage に保存する
  • ❌ v-html にユーザー入力を渡す → XSS
  • ❌ entrypoints/ に部品コンポーネントを置く
  • ❌ 型チェックを CI で実行していない

まとめ

  1. 構成は Inertia を第一候補に、既存アプリなら部分導入、API 共有が必要なときだけ完全分離。
  2. ビルドは Vite(vite_rails)。
  3. Vue 3 + <script setup> + TypeScript を標準にする。
  4. サーバーの状態はサーバーが持つ。クライアントでの重複管理を避ける。
  5. CSRF・シリアライズ・バリデーションは Rails の仕組みに乗る。
  6. 型チェック・Lint・テストを CI に組み込む。

0件のページ