laravel×Inertia.js×React×ViteでSSR
背景
laravel×Inertia.js×Reactで、Webアプリを実装しているが、
SEO対策のために、SSRをしたかった。
公式ドキュメントがあるため、
これの通りに進めたが、上手くいかずかなり詰まったので、方法をメモとして残す。
環境
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のとあるアイコンを含むコードを入れた瞬間、このエラーが出始めること
がわかった。
いろいろ調べた結果、
が症状として近そうだったので、これを参考にしたら上手くいった。
※この記事だと、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