[ゆるふわ] Next.js＋Railsの良いとこどりのアーキテクチャを考える

### なぜやるか

Rails7でHotwireが導入され、擬似SPA開発ができるようになり、
私的にはかなり開発体験もUXも向上したように感じます。
Hotiwireの威力をまだ感じてない人はぜひ使ってみて欲しいです！

しかし、フロントエンドにおいてDomの操作をガチャガチャし出すと、Hotwireではやはり辛くなります。
そこでガチャガチャしたいページはNext.js、それ以外のサクッと作りたいページをRailsで作れるようなプロジェクトを作ってみたいと思います。


# ▼ システムの全体
X(旧twwiter)にようなシステムを想定

### 画面
 ユーザー向け
- タイムライン(`/timeline`)
- 投稿画面(`/timepine/post`)
- ダッシュボード(`/dashboard')
- プロフィール(`/profile`)

システム管理者向け
- 管理画面

### 概要図
システムに基づいてページとシステム構成図を図示すると以下のイメージです。
タイムライン(`/timeline`)投稿画面(`/dashboard`)はUI/UXが複雑になると仮定して、SPA(nextjs)で、
その他はRailsでレンダリングとしてみました。

![Monosnap 無題のプレゼンテーション - Google スライド 2024-03-15 18-18-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182994/a339401c-24ea-2480-32d2-2dbefd69ab18.png)



# ▼ 構築してみる
### 主要な技術
- バックエンド
    - 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のプロジェクト構築準備
```sh
$ mkdir backend
$ cd backend
$ touch Dockerfile.build
$ touch docker-compose.yml
```

```Dockerfile.build
# rails構築用イメージ
FROM ruby:3.1.0
RUN mkdir /app
WORKDIR /app
```

```docker-compose.yml
version: '3'
services:
  rails-build:
    build:
      context: .
      dockerfile: Dockerfile.build
    volumes:
      - ./backend:/app
```

#### 2.Railsの立ち上げ
```bash
$ 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ファイルは削除

```routes.rb
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

```

ルーティングはこんな感じになります。
```bash
$ 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立ち上げ
```bash
$ 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
```

```timelines.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;
```

```timelinse/[id].tsx
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;
```

最終的なファイル構成
```diff
$ 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
```

#### [鬼門] ルーティングの振り分け設定をする
:::note info
ここが鬼門です
railsとnextをルーティングするためのサーバーを立てます
:::

```
# サーバを建てるためのライブラリをインストール
$ npm install -d express http-proxy

サーバー用のファイルをnext.jsのルートに作成
$ touch server.js
```


各パスを転送する場合は、以下のようにして転送します。`targetHost`はrailsのhostです。
`    proxy.web(req, res, { target: `${targetHost}${req.originalUrl}` });`

```server.js
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}`);
  });
});
```

起動コマンドを書き換えます。
```diff_json:packege.json
"scripts": {
-   "dev": "next dev",
+   "dev": "node server.js",
    "build": "next build",
-　　　　 "start": "next start",
+   "start": "NODE_ENV=production node server.js",
    "lint": "next lint"
},
```

以上で完成になります！


# ▼ 完成物
本日のコードはこちらにアップしました。

https://github.com/tikaranimaru/yurufuwa-next-rails

# ▼ 最後に
### 課題感
- UIの二重管理
    - 特にlayoutをNextとerbでそれぞれ作らないといけない
- Nextで作ったcomponentをRails側で使えない
    - Reactとして、もしかしたら読み込めるかも？
- 認証をどうするか
    - auth0とか使えば大した問題ではない
- server.jsの見通しが悪い

### メリット
- 管理画面などはsccafoldで鬼速開発できる
- バックエンドは統一してRailsを使っているので、後で部分的にSPA化したり、全てSPA化するのも比較的容易
- erbにreactをマウントする手もあるが、今回の方法の方が見通し・分離性が良さそう
    - reactマウント方式は分離の基準が難しく、erbがカオスになりがち
- 複数のプロジェクトに分かれたりしていないので、開発環境、DBもmigrationなども管理しやすい



以上、間違い、ご意見などありましたらコメントください。