📑

laravel×Inertia.js×React×ViteでSSR

2023/11/16に公開

背景

laravel×Inertia.js×Reactで、Webアプリを実装しているが、
SEO対策のために、SSRをしたかった。
https://inertiajs.com/server-side-rendering#running-the-ssr-server
公式ドキュメントがあるため、
これの通りに進めたが、上手くいかずかなり詰まったので、方法をメモとして残す。

環境

laravel ^11.0
Inertia ^1.0.0
vite ^5.1.6
react ^18.2.0

手順

下準備

とりあえず、公式のドキュメント通りに進める。
ただし、既にアプリはかなり開発していたので、

php artisan breeze:install react --ssr

はスキップ。
※最後にも書いているが、開発開始時から↑で作り始めた方が楽。

次に、一応、言われた通り、

composer require inertiajs/inertia-laravel

で、Laravel adapter?を最新化。

ssr.tsxを作成

ドキュメントに従って、

touch resources/js/ssr.js

・・・。
このまま進めるとビルドでエラーになった。

touch resources/js/ssr.jsx

で、jsxファイルにする。
こうしないと、
renderで

return (
	<App {...props} />		
    );

のところでエラーになる。

typescript化

今回はtsxにしたいので、ssr.tsxにファイル名を変える。
このとき、typescriptにいろいろ文句を言われる。
試しに、別のプロジェクトをつくって、

php artisan breeze:install react --ssr --typescript

で、スターターキットを使ってみると、typescriptによるエラーは出なかったので、
それを生成されたファイルを真似て、下記の3点の修正した。

1. package.jsonで、各ライブラリを更新
パッケージが古いと、型定義ファイルがなかったりしてエラーが出るっぽい。

下記のバージョン指定で筆者はできたので参考までに。
関係ないライブラリも含まれているので、それは適宜無視してください。

{
    "private": true,
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "vite build && vite build --ssr",
	// ↑これは、後述
        "postinstall": "npm run build",
    },
    
    "devDependencies": {
        "@tailwindcss/forms": "^0.5.7",
        "@types/node": "^20.11.27",
        "@types/react": "^18.2.65",
        "@types/react-dom": "^18.2.22",
        "@typescript-eslint/eslint-plugin": "^7.2.0",
        "@vitejs/plugin-react": "^4.2.1",
        "eslint": "^8.57.0",
        "eslint-config-prettier": "^9.1.0",
        "eslint-plugin-import": "^2.29.1",
        "eslint-plugin-react": "^7.34.0",
        "eslint-plugin-tailwindcss": "^3.15.1",
        "eslint-plugin-unused-imports": "^3.1.0",
        "laravel-vite-plugin": "^1.0.2",
        "prettier": "^3.2.5",
        "prettier-plugin-tailwindcss": "^0.5.12",
        "tailwindcss": "^3.4.1",
        "typescript": "^5.4.2",
        "vite": "^5.1.6",
    },
    "dependencies": {
        "@emotion/react": "^11.11.4",
        "@emotion/styled": "^11.11.0",
        "@headlessui/react": "^1.7.18",
        "@inertiajs/react": "^1.0.15",
        "autoprefixer": "^10.4.18",
        "axios": "^1.6.7",
        "postcss": "^8.4.35",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "react-icons": "^5.0.1",
        "ziggy-js": "^2.0.4"
    }
}

2. 型定義ファイルを作成
js配下のTypesフォルダへ、

  • global.d.ts
    ここで、routeを型定義することで、tsxファイルで、route()が使える。
import { AxiosInstance } from "axios";
import { route as ziggyRoute } from "ziggy-js";

declare global {
    interface Window {
        axios: AxiosInstance;
    }

    // eslint-disable-next-line no-var
    var route: typeof ziggyRoute;
}
  • vite-env.ts
    これは、import.meta.env.VITE_APP_NAMEのように、VITEで環境変数を読み込むのに必要
/// <reference types="vite/client" />

の3ファイルを作成。

3. tsconfig.jsonを修正

{
    "compilerOptions": {
        "allowJs": true,
        "module": "ESNext",
        "moduleResolution": "bundler",
        "jsx": "react-jsx",
        "strict": true,
        "isolatedModules": true,
        "target": "ESNext",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "paths": {
            "@/*": ["./resources/js/*"],
            "ziggy-js": ["./vendor/tightenco/ziggy"]
        }
    },
    "include": [
        "resources/js/**/*.ts",
        "resources/js/**/*.tsx",
        "resources/js/**/*.d.ts"
    ],
    "exclude": ["node_modules", "vendor"]
}

ssr.tsxの中身の編集

次に、公式の通り、
ssr.tsxを編集。

import { createInertiaApp } from "@inertiajs/react";
import createServer from "@inertiajs/react/server";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import ReactDOMServer from "react-dom/server";
import { route, RouteName, RouteParams } from "ziggy-js";

const appName = import.meta.env.VITE_APP_NAME || "Laravel";

createServer((page) =>
    createInertiaApp({
        page,
        render: ReactDOMServer.renderToString,
        title: (title) => `${title} - ${appName}`,
        resolve: (name) =>
            resolvePageComponent(
                `./Pages/${name}.tsx`,
                import.meta.glob("./Pages/**/*.tsx")
            ),
        setup: ({ App, props }) => {
            global.route<RouteName> = (name, params, absolute) =>
                route(name, params as RouteParams<RouteName>, absolute, {
                    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                    // @ts-expect-error
                    ...page.props.ziggy,
                    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                    // @ts-expect-error
                    location: new URL(page.props.ziggy.location),
                });

            return (
                <App {...props} />
            );
        },
    })
);

ポイント①

自分の場合、Contextも使っていたので、
<App {...props} />は、そのContextで実際は囲った。

ポイント②

setupの中は、

global.route<RouteName> = (name, params, absolute) =>
    route(name, params as RouteParams<RouteName>, absolute, {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-expect-error
        ...page.props.ziggy,
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-expect-error
        location: new URL(page.props.ziggy.location),
    });

を追加している。
これがないと、ビルド後に、routeがないぞ!みたいなエラーが出た。
同様に、global.routeをrouteにするとエラーがでる。

route内は、typescriptのエラーが出てしまうが、スターターキットでも、
// @ts-expect-error
と書かれていてエラーが出るもののようなので、無理矢理消している。

ポイント③

titleで、app.tsxのように、

const appName =
    window.document.getElementsByTagName("title")[0]?.innerText || "Laravel";

を使っていると、windowがないぞと言われるので、
.envに、

VITE_APP_NAME="アプリ名"

を追加して、

const appName =
    import.meta.env.VITE_APP_NAME || "Laravel";

に変える。

ポイント④

スターターキットでは、

import route from "../../vendor/tightenco/ziggy/dist/index.m";

のように、routeを型定義しているが、
そのようにして、Herokuにデプロイしたらビルド時にエラーが出た。
(なぜか、ローカルで、npm run buildをしても出なかった。)
なので、

import route, { RouteName } from "ziggy-js";

のようにrouteを定義したらうまくいった。

vite.config.jsの修正

次に、
vite.config.jsを編集。

import react from "@vitejs/plugin-react";
import laravel from "laravel-vite-plugin";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [
        laravel({
            input: "resources/js/app.tsx",
            ssr: "resources/js/ssr.tsx",
            refresh: true,
        }),
        react(),
    ],
    ssr: {
        noExternal: [
            "@mui/material",
            "@mui/utils",
            "@mui/base",
            "@mui/icons-material",
        ],
    },
});

ポイント

ssr: {
        noExternal: [
            "@mui/material",
            "@mui/utils",
            "@mui/base",
            "@mui/icons-material",
        ],
    },

ここ。
これを書いておかないと、ビルドは成功しても、アクセスしたときに、

Element type is invalid

みたいなエラーが出て、画面は真っ白になる。(npm run devの場合は、エラーにならない)
これが原因がわからず、かなり迷走した。

最初のbreezeのスターターキットだと、このエラーは出ないのに、開発中の自分のアプリだとエラーになり続けた。。
自分の開こうとしているコンポーネントの問題だろうと考えて、
ssr.tsxに空の状態から、順に元のコンポーネントの要素を加えていったところ、
MaterialIconのとあるアイコンを含むコードを入れた瞬間、このエラーが出始めること
がわかった。
いろいろ調べた結果、
https://github.com/mui/material-ui/issues/37375
が症状として近そうだったので、これを参考にしたら上手くいった。
※この記事だと、noExternalを上記のようにすると、yarn devが上手くいかなかったらしいが、自分はnpm run devも問題なくできていた。

buildコマンドを追記

package.jsonを、

build": "vite build && vite build --ssr

に書き換え。

npm run build

でビルドが成功するはず。

成功したら、

php artisan serve

とあわせて、

php artisan inertia:start-ssr

を行う。
で、ブラウザでアプリを開くが、このとき、ブラウザのjavascriptをオフにしてみる。
Chromeの場合、URLバーの左側のカギマークから「サイトの設定」でオフにできる。
うまくいっていれば、これをしてもページがちゃんと開ける。

ハイドレーションを適用

app.tsxを下記のように編集。

import "./bootstrap";
import "../css/app.css";

import { createInertiaApp } from "@inertiajs/react";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { hydrateRoot } from "react-dom/client";


const appName =
   window.document.getElementsByTagName("title")[0]?.innerText || "Laravel";

createInertiaApp({
   title: (title) => `${title} - ${appName}`,
   resolve: (name) =>
       resolvePageComponent(
           `./Pages/${name}.tsx`,
           import.meta.glob("./Pages/**/*.tsx")
       ),
   setup({ el, App, props }) {
       hydrateRoot(
           el,
           <App {...props} />
       );
   },
   progress: {
       color: "#4B5563",
   },
});

これで本番環境は問題ないが、 npm run devをしたとき、ブラウザのconsoleに

Expected server HTML to contain a matching <div> in <div> .

のような警告が出る。
buildではでなかったが、原因がわからず。
無視するのも気分が悪いので、開発環境では、通常のcreateRootになるように、

if (import.meta.env.VITE_APP_ENV === "production") {
   hydrateRoot(
   el,
       <App {...props} />
   );
} else {
   createRoot(el).render(
       <App {...props} />
   );
}

のように無理矢理分岐した。

buildで生成されるファイルをgit管理対象外に

このままだと、npm run build時に生成されたファイルがgitで管理されてしまうので、
プロジェクト直下の .gitignoreに、

/bootstrap/ssr

を追加して、git管理対象から外しておく。

Procfileの修正(Herokuデプロイ用)

Herokuにデプロイする場合は、
Procfileを

web: php artisan inertia:start-ssr & vendor/bin/heroku-php-apache2 public/

下記のようにする。


かなり、難航したので、書き残しておきました。
本当は、laravel breezeを使っているので、

php artisan breeze:install react --ssr --typescript

をして、開発を始めていたら、ここまで難航しなかったと思う。。
公式のドキュメントに書かれていない、setup内に記載するrouteのところなんかも、breezeのスターターキットでファイルを作成すると書かれていたりした。

まぁ、なんにせよ、フロントエンドの技術としてviteなんかの勉強にはなったかな・・・

※追記

別アプリで、
react-material-ui-carousel
を使っているが、そこでSSRのエラーが出てしまう。(ユーザーの画面上はでないが)
上述のmaterial iconsと同様に、

ssr: {
       noExternal: [
           "@mui/material",
           "@mui/utils",
           "@mui/base",
           "@mui/icons-material",
       ],
   },

ここに加えればいいかと思ったが、追加しても、別のエラーで上手くいかない。
今回は、そのカルーセル自体を残すか検討中だったため、その場しのぎとして、

const [isClient, setIsClient] = useState(false);
   useEffect(() => {
       setIsClient(true);
   }, []);
 {isClient && (
    <Carousel ...中略... />
)}

をカルーセルを使っているコンポーネントに追記し、無理矢理SSRでカルーセルを表示しないようにした。
正しい解決策をご存じの方いたら、ご教示ください・・・。。

Discussion