1
0

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

Posted at

なぜやるか

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

▼ 構築してみる

主要な技術

  • バックエンド
    • 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
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の立ち上げ

$ 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

ルーティングはこんな感じになります。

$ 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
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;

最終的なファイル構成

$ 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} });

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

起動コマンドを書き換えます。

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"
},

以上で完成になります!

▼ 完成物

本日のコードはこちらにアップしました。

▼ 最後に

課題感

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

メリット

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0