Express+ReactでSSRしようとして静的ファイルの読み込みに四苦八苦した話

express.staticミドルウェア関数が、Expressアプリケーションを起動するディレクトリからの相対パスとなることに気付かずにハマった。

はじめに

とある Web サイトで挑戦中の技術課題に「Next.js を使わず React で SSR しよう」というものがあり、 Express アプリケーションで SSR した React アプリケーションから静的ファイルを読み込もうとしたのだけど、なかなかうまくいかず四苦八苦した。

結論

静的ファイルを Express アプリケーションでサーブする時に使うexpress.staticという関数がある。
この関数に静的ファイルまでの相対パスを指定するとき、実行する Express アプリケーションからの相対パスではなく、Express アプリケーションを起動するディレクトリからの相対パスとなる。
Express アプリケーションをpackage.jsonの script から起動するときは注意しよう。

構成

ライブラリ

ライブラリバージョン
Node.js19.4.0
React18.2.0
express4.18.2

ディレクトリ

.
├── dist/ # ビルドしたファイルの出力先
 ├── client.js # Reactアプリケーションをビルドしたファイル
 └── server.js # Expressアプリケーションをビルドしたファイル
├── node_modules
├── src/
 ├── client.tsx # Reactアプリケーション本体
 ├── index.tsx # Reactアプリケーションをhydrateするファイル
 └── server.tsx # Expressアプリケーションの実装
├── .gitignore
├── package-lock.json
├── package.json
├── tsconfig.json
└── webpack.config.ts

ハマったポイント

Express アプリケーションで SSR した React アプリケーションをクライアントサイドで hydrate した時に、useState hook で state を更新できなかった。 Express アプリケーションの当初の実装は以下の通り。

server.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import express from "express";
import Client from "./client";
import { renderToString } from "react-dom/server";

const app = express();
const port = 3000;

app.use(express.static("./"));

app.listen(port, () => {
  console.log(`listening on port ${port}`);
});

const reactComponent = renderToString(<Client />);

const pageHtml = `
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>counter</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
    </head>
    <body>
        <div id="react-root">${reactComponent}</div>
        <script defer="defer" src="client.js"></script>
    </body>
</html>
`;

app.get("/", (_req, res) => {
  res.send(pageHtml);
});

server.tsxをビルドしてdist/server.jsを出力していたため、8 行目をexpress.static("./")とすれば、同じディレクトリに出力したdist/client.jsが読み込める想定だった。
Webpack を利用するのが初めてだったため、ビルドの設定に不備があるのかと Webpack 周りをひたすらググった後に、<script defer="defer" src="client.js"></script>でサーブしているclient.jsが 404 になっていることに気付いた。

package.json
{
  // 省略
  "scripts": {
    "dev": "webpack && node dist/server.js"
  },
  // 省略
}

上記はプロジェクトルートにあるpackage.jsonの抜粋。
原因はプロジェクトルートにあるpackage.jsonの script からnpm run devコマンドで Express アプリケーションを起動していたためだった。
server.tsxの 8 行目をexpress.static("dist")に修正したところdist/client.jsが読み込まれ、state の更新ができるようになった。

server.tsx
 6
 7
 8
 9
10
const port = 3000;

app.use(express.static("dist"));

app.listen(port, () => {

サンプル

以下の GitHub リポジトリに本記事で使用したサンプルコードを公開している。

GitHub - nns7/express-ssr-example

Contribute to nns7/express-ssr-example development by creating an account on GitHub.

参考

Built with Hugo
テーマ StackJimmy によって設計されています。