なぜやるか
Rails7でHotwireが導入され、擬似SPA開発ができるようになり、
私的にはかなり開発体験もUXも向上したように感じます。
Hotiwireの威力をまだ感じてない人はぜひ使ってみて欲しいです!
しかし、フロントエンドにおいてDomの操作をガチャガチャし出すと、Hotwireではやはり辛くなります。
そこでガチャガチャしたいページはNext.js、それ以外のサクッと作りたいページをRailsで作れるようなプロジェクトを作ってみたいと思います。
▼ システムの全体
X(旧twwiter)にようなシステムを想定
画面
ユーザー向け
- タイムライン(
/timeline
) - 投稿画面(
/timepine/post
) - ダッシュボード(`/dashboard')
- プロフィール(
/profile
)
システム管理者向け
- 管理画面
概要図
システムに基づいてページとシステム構成図を図示すると以下のイメージです。
タイムライン(/timeline
)投稿画面(/dashboard
)はUI/UXが複雑になると仮定して、SPA(nextjs)で、
その他はRailsでレンダリングとしてみました。
▼ 構築してみる
主要な技術
- バックエンド
- Ruby on Rails 7系
- Ruby 3.1系
- Hotwire
- フロントエンド
- Next.js 14系
- npm
- UI
- DaisyUI(tailwind css)
- その他:
- Docker
ディレクトリ
frontとbackendでそれぞれrootを分けて作っていきます
% tree
.
├── README.md
├── docker-compose.yml
├── .env
├── backend // railsのルート
│ └── Dockerfile.backend
└── frontend // next.jsのルート
└── Dockerfile.frontend
Railsの構築
1.Railsのプロジェクト構築準備
$ mkdir backend
$ cd backend
$ touch Dockerfile.build
$ touch docker-compose.yml
# rails構築用イメージ
FROM ruby:3.1.0
RUN mkdir /app
WORKDIR /app
version: '3'
services:
rails-build:
build:
context: .
dockerfile: Dockerfile.build
volumes:
- ./backend:/app
2.Railsの立ち上げ
$ docker-compose build
$ docker-compose run rails-build bash
# 以下コンテナ内で実行
rails-build]$ gem install rails -v 7.1.2
rails-build]$ rails new . -d mysql –skip-action-mailer –skip-action-text –force --css tailwind -j importmap
# 起動できればOK
rails-build]$ rails s
=> Booting Puma
=> Rails 7.1.3.2 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 6.4.2 (ruby 3.1.0-p0) ("The Eagle of Durango")
* Min threads: 5
* Max threads: 5
* Environment: development
* PID: 960
* Listening on http://127.0.0.1:3000
Use Ctrl-C to stop
その他細かいDBの接続は割愛...
3. ページ、モデル、コントローラー類を作成
コマンドサクッと作ってしまいます。
bin/rails g scaffold User name:string email:string password_digest:string
bin/rails g scaffold timeline user_id:integer datetime:string title:string content:text
bin/rails g controller admin/dashboard index
bin/rails db:migrate
ルーティングをapiとそうでないもので分けて作ります。
scaffoldで作られた不要なviewファイルは削除
Rails.application.routes.draw do
#
# api
#
namespace :api do
resources :timelines, only: [:index, :show]
end
#
# erbの画面
#
resources :timelines, only: [:new, :create]
resources :users, except: [:destroy, :index], path: 'users/profile'
#
# 管理画面 /admin
#
namespace :admin do
get 'dashboard', to: 'dashboard#index'
end
end
ルーティングはこんな感じになります。
$ bin/rails routes
Prefix Verb URI Pattern Controller#Action
api_timelines GET /api/timelines(.:format) api/timelines#index
api_timeline GET /api/timelines/:id(.:format) api/timelines#show
timelines POST /timelines(.:format) timelines#create
new_timeline GET /timelines/new(.:format) timelines#new
users POST /users/profile(.:format) users#create
new_user GET /users/profile/new(.:format) users#new
edit_user GET /users/profile/:id/edit(.:format) users#edit
user GET /users/profile/:id(.:format) users#show
PATCH /users/profile/:id(.:format) users#update
PUT /users/profile/:id(.:format) users#update
admin_dashboard GET /admin/dashboard(.:format)
rails側は最終的にはこんな感じになりました。
% tree backend/app
(git)-[main]
app
├── assets
├── channels
├── controllers
│ ├── admin
│ │ └── dashboard_controller.rb
│ ├── api
│ │ └── timelines_controller.rb
│ ├── application_controller.rb
│ ├── concerns
│ ├── timelines_controller.rb
│ └── users_controller.rb
├── helpers
├── javascript
├── jobs
├── mailers
├── models
│ ├── application_record.rb
│ ├── concerns
│ ├── timeline.rb
│ └── user.rb
└── views
├── admin
│ └── dashboard
│ └── index.html.erb
├── layouts
│ ├── application.html.erb
│ ├── mailer.html.erb
│ └── mailer.text.erb
├── timelines
│ ├── _form.html.erb
│ └── new.html.erb
└── users
├── _form.html.erb
├── _user.html.erb
├── edit.html.erb
├── new.html.erb
└── show.html.erb
Next.jsの構築
Next.js立ち上げ
$ npx create-next-app@latest
✔ What is your project named? … frontend
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /frontend.
ページを作っていく
$ touch pages/timelines.tsx
$ mkdir pages/timelines
$ touch pages/timelines/[id].tsx
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Timeline {
id: number;
user_id: number;
datetime: string;
title: string | null;
content: string;
created_at: string;
updated_at: string;
}
const TimelinesPage = () => {
const [timelines, setTimelines] = useState<Timeline[]>([]);
const userId = 1
useEffect(() => {
const fetchTimelines = async () => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_HOST}/api/timelines?user_id=${userId}`);
const data = await response.json();
setTimelines(data);
} catch (error) {
console.error('Error fetching timelines', error);
}
};
fetchTimelines();
}, []);
return (
<div className="container mx-auto p-4">
<a href="/timelines/new" className="mb-4 p-2 btn text-white bg-blue-500 rounded hover:bg-blue-700">
+ NEW
</a>
{timelines.map((timeline) => (
<div key={timeline.id} className="p-4 mb-2 bg-gray-100 rounded shadow">
<div className="text-black text-sm">{timeline.datetime}</div>
<div className="text-black text-lg font-semibold">{timeline.title || 'No Title'}</div>
<p className="text-gray-800">{timeline.content}</p>
<Link href={`/timelines/${timeline.id}`} className="text-blue-500 hover:underline">
詳細を見る
</Link>
</div>
))}
</div>
);
};
export default TimelinesPage;
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
interface Timeline {
id: number;
user_id: number;
datetime: string;
title: string | null;
content: string;
created_at: string;
updated_at: string;
}
const TimelineDetailPage = () => {
const [timeline, setTimeline] = useState<Timeline | null>(null);
const router = useRouter();
const { id } = router.query;
useEffect(() => {
const fetchTimeline = async () => {
if (id) {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_HOST}/api/timelines/${id}`);
const data = await response.json();
setTimeline(data);
} catch (error) {
console.error('Error fetching timeline', error);
}
}
};
fetchTimeline();
}, [id]);
if (!timeline) {
return <div>Loading...</div>;
}
return (
<div className="container mx-auto p-4">
<div className="p-4 mb-2 bg-gray-100 rounded shadow">
<div className="text-gray-600 text-sm">{timeline.datetime}</div>
<div className="text-lg font-semibold">{timeline.title || 'No Title'}</div>
<p className="text-gray-800">{timeline.content}</p>
</div>
<button onClick={(e) => {router.back()}} className="mb-4 p-2 btn text-white rounded">戻る</button>
</div>
);
};
export default TimelineDetailPage;
最終的なファイル構成
$ tree
:
:
frontend
├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ └── hello.ts
│ ├── index.tsx
+│ ├── timelines
+│ │ └── [id].tsx
+│ └── timelines.tsx
├── postcss.config.js
├── public
│ ├── favicon.ico
│ ├── next.svg
│ └── vercel.svg
├── server.js
├── styles
│ └── globals.css
├── tailwind.config.ts
└── tsconfig.json
[鬼門] ルーティングの振り分け設定をする
ここが鬼門です
railsとnextをルーティングするためのサーバーを立てます
# サーバを建てるためのライブラリをインストール
$ npm install -d express http-proxy
サーバー用のファイルをnext.jsのルートに作成
$ touch server.js
各パスを転送する場合は、以下のようにして転送します。targetHost
はrailsのhostです。
proxy.web(req, res, { target:
${targetHost}${req.originalUrl} });
const express = require('express');
const next = require('next');
const httpProxy = require('http-proxy');
const port = parseInt(process.env.PORT, 10) || 3000;
const targetHost = process.env.BACKEND_HOST || 'http://rails:8000';
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
const proxy = httpProxy.createProxyServer();
app.prepare().then(() => {
const server = express();
// railsのassets類
server.use('/assets/*', (req, res) => {
console.log(`Routing /assets/* to ${targetHost}${req.originalUrl}`);
proxy.web(req, res, { target: `${targetHost}${req.originalUrl}` });
});
// adminは全てrailsで担う
server.use('/admin/*', (req, res) => {
console.log(`Routing /admin/* to ${targetHost}${req.originalUrl}`);
proxy.web(req, res, { target: `${targetHost}${req.originalUrl}` });
});
server.use('/users/profile/*', (req, res) => {
console.log(`Routing /users/profile/* to ${targetHost}${req.originalUrl}`);
proxy.web(req, res, { target: `${targetHost}${req.originalUrl}` });
});
server.use('/timelines/new', (req, res) => {
console.log(`Routing /timelines/new to ${targetHost}/timelines/new`);
proxy.web(req, res, { target: `${targetHost}/timelines/new` });
});
server.post('/timelines', (req, res) => {
console.log(`Routing /timelines post to ${targetHost}/timelines`);
proxy.web(req, res, { target: `${targetHost}` }); // /timelinesをつけ足す必要はない
});
// 他のHTTPメソッドの/timelinesへのリクエストはNext.jsのハンドラーに渡す
server.all('/timelines', (req, res) => {
if (req.method !== 'POST') {
return handle(req, res);
}
});
// その他のリクエストはNext.jsのハンドラーに渡す
server.all('*', (req, res) => {
console.log(`Passing all other requests to Next.js handler`);
return handle(req, res);
});
server.listen(port, (err) => {
if (err) {
console.error('Error starting server:', err);
throw err;
}
console.log(`> Ready on http://localhost:${port}`);
});
});
起動コマンドを書き換えます。
"scripts": {
- "dev": "next dev",
+ "dev": "node server.js",
"build": "next build",
- "start": "next start",
+ "start": "NODE_ENV=production node server.js",
"lint": "next lint"
},
以上で完成になります!
▼ 完成物
本日のコードはこちらにアップしました。
▼ 最後に
課題感
- UIの二重管理
- 特にlayoutをNextとerbでそれぞれ作らないといけない
- Nextで作ったcomponentをRails側で使えない
- Reactとして、もしかしたら読み込めるかも?
- 認証をどうするか
- auth0とか使えば大した問題ではない
- server.jsの見通しが悪い
メリット
- 管理画面などはsccafoldで鬼速開発できる
- バックエンドは統一してRailsを使っているので、後で部分的にSPA化したり、全てSPA化するのも比較的容易
- erbにreactをマウントする手もあるが、今回の方法の方が見通し・分離性が良さそう
- reactマウント方式は分離の基準が難しく、erbがカオスになりがち
- 複数のプロジェクトに分かれたりしていないので、開発環境、DBもmigrationなども管理しやすい
以上、間違い、ご意見などありましたらコメントください。