Rails アプリケーションで Vue 3 を使うときの構成の選び方、セットアップ、設計・実装の指針をまとめたものです。
目次
- まず構成パターンを選ぶ
- ビルドツール:Vite を使う
- ディレクトリ構成
- パターン別の実装
- Vue 側のコーディング指針
- Rails との連携で気をつけること
- テスト
- 開発体験・品質
- デプロイ・本番
- よくあるアンチパターン
- まとめ
1. まず構成パターンを選ぶ
Rails と Vue の組み合わせ方は大きく 3 つあります。最初にどれを採用するかで、その後の設計がほぼ決まります。
| パターン | 概要 | 向いているケース |
|---|---|---|
| A. アイランド型(部分導入) | ERB で画面を描画し、一部の要素だけ Vue コンポーネントをマウントする | 既存の Rails アプリに少しずつ導入したい/インタラクティブな箇所が限定的 |
| B. Inertia.js | Rails のルーティング・コントローラーをそのまま使い、ビュー層を 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への保存は避け、HttpOnlyCookie を検討する。 - 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-routesgem で Rails のルートヘルパーを TypeScript から使えるようにすると安全。
バリデーション
- バリデーションの正はサーバー(モデル)。フロントのバリデーションは UX のための補助と考える。
- エラーは
errors.messagesの形式で返し、フロント側で項目ごとに表示する共通コンポーネントを作る。
N+1 対策
- Vue に渡すデータを作る段階で
includes/preloadを使う。props に関連データを詰め込むと N+1 が起きやすい。bulletgem で検出する。
7. テスト
| 対象 | ツール | 方針 |
|---|---|---|
| Vue コンポーネント | Vitest + Vue Test Utils | props と表示、イベント発火を中心に検証 |
| composable | Vitest | 純粋なロジックとして単体テスト |
| Rails コントローラー | RSpec(request spec) | Inertia の場合は inertia_rails のテストヘルパーで描画コンポーネントと props を検証 |
| E2E | Capybara + 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 で実行していない
まとめ
- 構成は Inertia を第一候補に、既存アプリなら部分導入、API 共有が必要なときだけ完全分離。
- ビルドは Vite(
vite_rails)。 - Vue 3 +
<script setup>+ TypeScript を標準にする。 - サーバーの状態はサーバーが持つ。クライアントでの重複管理を避ける。
- CSRF・シリアライズ・バリデーションは Rails の仕組みに乗る。
- 型チェック・Lint・テストを CI に組み込む。