Reactで作成したSPAをNetlifyでホストする際のコツ

2022/04/28に公開

(2022年11月25日追記)
私の本が株式会社インプレス R&Dさんより出版されました。この記事の内容も含まれています。イラストは鍋料理さんの作品です。猫のモデルはなんとうちのコです!

https://www.amazon.co.jp/dp/B0BMPZW444/

感想を書いていただけるととても嬉しいです!

(2022年8月3日追記)この記事の内容はこちらの本でも読めます。

https://zenn.dev/sikkim/books/how_to_create_api_sales_service

はじめに

現在作成中のWebサービスが大分できあがってきたので、Netlifyでホストしてみました。Reactで作ったSPAをNetlifyで動かすのは意外と手間がかかったので、ハマりそうになったところを共有します。今回デプロイしたWebサービスには以下の特徴があります。似たようなことをしようとしている方の参考になれば幸いです。

  • Vite + React + TypeScript + TailwindCSSで構築
  • React Router v6でルーティング
  • CognitoでGoogle認証する
  • Amplifyを認証のためだけに使用する

ホスティングサービスとしてNetlifyを選んだのは、ふだんから使い慣れていることと、問い合わせフォームを簡単に設置できるためです。

事前準備

デプロイする前にいろいろと準備が必要でした。

ビルド

Netlifyには静的なファイルしかホストできないので、ビルドしてデプロイ用のファイルを準備します。

npm run build

ビルド結果はdistディレクトリに生成されます。実はプロジェクトを開始してから今回始めてビルドしたので、いろいろなエラーが出ました。ビルド時のエラーをひとつひとつ潰していく作業は結構好きなので楽しかったです。npm run devで動かしていたときは問題なかったのに、ビルドするとエラーになる事象もあって興味深かったです。エラーメッセージを読めば対処できるものがほとんどでしたが、面倒だった2件だけ以下に記載しておきます。

react-router-hash-linkのエラーに対処する

ページ内遷移させるためにreact-router-hash-linkを使っていたのですが、これはReact Router v4とv5にしか対応していないことがわかりました。最初はソースコードをforkして自力で対応しようかと思いましたが、調べたら同じことをすでにやっていた方がいました。インポート元を@xzar90/react-router-hash-linkに書き換えたらビルドが通るようになりました。

Amplifyのエラーに対処する

ほかのエラーを全部対処したら最後に以下のエラーが残りました。

'request' is not exported by __vite-browser-external, imported by node_modules/@aws-sdk/credential-provider-imds/dist/es/remoteProvider/httpRequest.js

これはViteとAmplifyの組み合わせで発生するエラーのようで、こちらのIssueを参考に対応しました。vite.config.tsを以下のように書き換えたらビルドが通るようになりました。

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
+ resolve: {
+   alias: {
+     './runtimeConfig': './runtimeConfig.browser',
+   },
+ },
});

ビルド後の動作確認

ビルドが成功したら、distに生成されたファイルの動作確認を行います。私はよく下記のコマンドで簡易HTTPサーバーを立てて静的HTMLの動作確認をしますが、SPAの場合はこの方法では問題があります。

cd dist
python3 -m http.server {ポート番号}

ルートにアクセスしたり、リンクをクリックして画面遷移する分には良いのですが、URLを指定してルート以外にアクセスすると404エラーになってしまいます。distにはルートにしかindex.htmlが存在しないので、考えてみたら当たり前ですね。ではなぜnpm run devで動かしていたときはURLを直接指定しても正常に表示されていたかというと、ルート以外へのアクセスをテストサーバーがルートにリダイレクトしてくれていたからです。したがってdistのテストをするときは、すべてのアクセスをルートにリダイレクトしてやれば開発時の動きを再現できます。Pythonでもできますがやや面倒なので、お手軽に確認したい場合はNode.jsのservorを利用するとよいでしょう。カレントディレクトリを3000番ポートで確認したい場合は以下のようにします。

npx servor . index.html 3000

引数の意味は以下の通りです。

servor {ルートディレクトリ} {ファイル名} {ポート番号}

Netlify用のリダイレクト設定を追加

上記の問題はNetlifyにデプロイしたときも発生します。Netlifyですべてのアクセスをルートのindex.htmlにリダイレクトするのは簡単で、以下の内容のnetlify.tomlをReactプロジェクトのルートに配置しておくだけです。

netlify.toml
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Amplifyの設定情報を環境変数に移動

今まではAmplifyの設定をawsExports.tsにベタ書きしてローカルのみで管理していましたが、この方法ではGitHubリポジトリとNetlifyの連動に支障をきたします。そこでawsExports.tsはリポジトリ上で管理することにして、秘密にしたい値は環境変数に追い出します。新たなawsExports.tsは次のようにしました。

awsExports.ts
// Amplifyの設定
const awsExports = {
  Auth: {
    region: import.meta.env.VITE_REGION,
    userPoolId: import.meta.env.VITE_USER_POOL_ID,
    userPoolWebClientId: import.meta.env.VITE_USER_POOL_WEB_CLIENT_ID,
    oauth: {
      domain: import.meta.env.VITE_OAUTH_DOMAIN,
      scope: ['openid'],
      redirectSignIn: import.meta.env.VITE_OAUTH_REDIRECT_SIGN_IN,
      redirectSignOut: import.meta.env.VITE_OAUTH_REDIRECT_SIGN_OUT,
      responseType: 'code',
    },
  },
};

export default awsExports;

Viteで扱う環境変数は、このようにVITE_を接頭語にする必要があります。

ローカル環境の環境変数は.env.localに書いておくとよいでしょう。.env.localはViteではデフォルトでgitignoreの対象になっています。

.env.local
VITE_REGION=ap-northeast-1
VITE_USER_POOL_ID=ap-northeast-1_XXXXXXXXX
VITE_USER_POOL_WEB_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXX
VITE_OAUTH_DOMAIN=XXXXXXXXXXXXXXXXXXXXXXX.amazoncognito.com
VITE_OAUTH_REDIRECT_SIGN_IN=http://localhost:3000/mypage
VITE_OAUTH_REDIRECT_SIGN_OUT=http://localhost:3000/

また、TypeScriptのエラーが出るので、vite-env.d.tsを以下のように修正しておきます。

vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_REGION: string;
  readonly VITE_USER_POOL_ID: string;
  readonly VITE_USER_POOL_WEB_CLIENT_ID: string;
  readonly VITE_OAUTH_DOMAIN: string;
  readonly VITE_OAUTH_REDIRECT_SIGN_IN: string;
  readonly VITE_OAUTH_REDIRECT_SIGN_OUT: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Netlifyにデプロイする

準備ができたらNetlifyにサインインして、GitHubのリポジトリを連携します。ビルドの設定は下図のようにします。

ビルドコマンドはnpm run build、公開ディレクトリはdist

環境変数はボタンをクリックしてAdvanced build settingを開くと設定できます。

環境変数の設定

VITE_OAUTH_REDIRECT_SIGN_INVITE_OAUTH_REDIRECT_SIGN_OUThttp://localhost:3000ではなくNetlifyのURLを指定する必要があるので注意しましょう。

Cognitoユーザープールの設定変更

Cognitoのユーザープールを開き、「ホストされたサインアップページとサインインページ」にNetlifyのURLを追加します。これでNetlifyにホストした環境でも正常にサインインできるようになります。

ホストされたサインアップページとサインインページの設定

問い合わせフォームを有効にする

問い合わせフォームはきちんと作ろうとすると意外に面倒です。Netlifyは面倒な問い合わせフォームを簡単に実装できるのが魅力のひとつとなっています。ただしReactから利用する場合は若干の手間がかかります。

ダミーのHTMLファイルが必要

NetlifyはホストされたHTMLファイルを解析し、条件に合致する<form>タグを見つけたら問い合わせフォーム機能を有効にします。ReactでビルドされたファイルはほとんどがJavaScriptでできているため、単純に<form>を実装しただけではNetlifyが認識できません。そこで、Netlifyにフォームの存在を教えるためのダミーのHTMLファイルを用意する必要があります。この方法は公式ドキュメント公式ブログに載っています。ダミーのHTMLファイルはプロジェクト直下のpublicディレクトリに格納します。

public/dummy_form.html
<!-- NetlifyのFormを利用するための静的HTML -->
<form
  name="contact"
  data-netlify="true"
  netlify-honeypot="bot-field"
  hidden
>
  <input type="text" name="name" />
  <input type="text" name="company" />
  <input type="email" name="email" />
  <input type="text" name="name" />
  <textarea name="message"></textarea>
</form>

data-netlify="true"がポイントで、<form>タグにこのオプションを追加することでNetlifyの問い合わせフォーム機能が有効になります。

Reactで実装された問い合わせフォームはこのようになっています。<input>タグのnamedummy_form.htmlに合わせる必要があります。

src/components/Contact.tsx
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { FC } from 'react';
import { HashLink } from '@xzar90/react-router-hash-link';

const Contact: FC = () => (
  <section id="Contact" className="bg-gray-100 py-6 sm:py-8 lg:py-12">
    <div className="mx-auto max-w-screen-2xl px-4 md:px-8">
      <div className="mb-10 md:mb-16">
        <h2 className="mb-4 text-center text-2xl font-bold text-gray-800 md:mb-6 lg:text-3xl">
          お問い合わせ
        </h2>
      </div>
      <form
        className="mx-auto grid max-w-screen-md gap-4"
        name="contact"
        method="POST"
        data-netlify="true"
      >
        <div className="sm:col-span-2">
          <label htmlFor="name" className="text-sm text-gray-800 sm:text-base">
            お名前<span className="required-dot text-red-500">*</span>
            <input
              name="name"
              type="text"
              className="w-full rounded border bg-gray-50 px-3 py-2 text-gray-800 outline-none ring-indigo-300 transition duration-100 focus:ring"
            />
          </label>
        </div>
        <div className="sm:col-span-2">
          <label
            htmlFor="company"
            className="text-sm text-gray-800 sm:text-base"
          >
            会社名
            <input
              name="company"
              type="text"
              className="w-full rounded border bg-gray-50 px-3 py-2 text-gray-800 outline-none ring-indigo-300 transition duration-100 focus:ring"
            />
          </label>
        </div>
        <div className="sm:col-span-2">
          <label htmlFor="email" className="text-sm text-gray-800 sm:text-base">
            メールアドレス<span className="required-dot text-red-500">*</span>
            <input
              name="email"
              type="email"
              className="w-full rounded border bg-gray-50 px-3 py-2 text-gray-800 outline-none ring-indigo-300 transition duration-100 focus:ring"
            />
          </label>
        </div>
        <div className="sm:col-span-2">
          <label
            htmlFor="message"
            className="text-sm text-gray-800 sm:text-base"
          >
            お問い合わせ内容
            <span className="required-dot text-red-500">*</span>
            <textarea
              name="message"
              className="h-64 w-full rounded border bg-gray-50 px-3 py-2 text-gray-800 outline-none ring-indigo-300 transition duration-100 focus:ring"
            />
          </label>
        </div>
        <input type="hidden" name="form-name" value="contact" />
        <div className="flex items-center justify-between sm:col-span-2">
          <button
            type="submit"
            className="inline-flex rounded border-0 bg-blue-500 py-3 px-6 text-lg text-white hover:bg-blue-600 focus:outline-none active:bg-blue-700"
          >
            送信
          </button>
        </div>
        <p className="text-xs text-gray-400">
          送信完了をもって
          <HashLink
            to="/privacy_policy#top"
            className="underline transition duration-100 hover:text-indigo-500 active:text-indigo-600"
          >
            プライバシーポリシー
          </HashLink>
          に同意したものとみなします。
        </p>
      </form>
    </div>
  </section>
);
export default Contact;

これでNetlifyが問い合わせフォームを認識できるようになりました。Netlify設定のForm notificationsでメールやSlackと連携させましょう。

サンクスページを表示したい

問い合わせフォームは動くようになりましたが、実際に送信するとNetlifyのデフォルトサンクスページが表示されてしまいます。自前のサンクスページに遷移させたいのですが、これが意外と難しいです。通常のHTMLなら<form>タグにaction="/thanks"などと設定するだけですが、Reactで同じことをすると404エラーになってしまいます。メカニズムはよくわかりませんが、actionによる遷移ではルートへの自動リダイレクトが効かないのでしょうか?この問題の解決方法をネットで調べましたが、見つかった方法は複雑な上にうまくいかなかった(一瞬サンクスページが表示されるが、結局デフォルトのページが表示されてしまった)ので、自分で頭を絞った結果、次の方法で解決しました。

まず、サンクスページに転送するだけの機能を持ったダミーのサンクスページを用意します。

public/dummy_thanks.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- 本当のthanksページに遷移させる -->
    <meta http-equiv="refresh" content="0;url=/thanks" />
    <title>FM Mail</title>
  </head>
  <body>
    <br />
  </body>
</html>

次に、ダミーフォームとReactで実装した問い合わせフォームの両方にaction="/dummy_thanks.html"を追加します。実験したところ、どちらか片方だけでは正常に機能しなかったので、両方に同じ記述を追加するのがポイントのようです。

public/dummy_form.html
<!-- NetlifyのFormを利用するための静的HTML -->
<form
  name="contact"
  data-netlify="true"
  netlify-honeypot="bot-field"
+ action="/dummy_thanks.html"
  hidden
>
  <input type="text" name="name" />
  <input type="text" name="company" />
  <input type="email" name="email" />
  <input type="text" name="name" />
  <textarea name="message"></textarea>
</form>
src/components/Contact.tsx
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { FC } from 'react';
import { HashLink } from '@xzar90/react-router-hash-link';

const Contact: FC = () => (
  <section id="Contact" className="bg-gray-100 py-6 sm:py-8 lg:py-12">
    <div className="mx-auto max-w-screen-2xl px-4 md:px-8">
      <div className="mb-10 md:mb-16">
        <h2 className="mb-4 text-center text-2xl font-bold text-gray-800 md:mb-6 lg:text-3xl">
          お問い合わせ
        </h2>
      </div>
      <form
        className="mx-auto grid max-w-screen-md gap-4"
        name="contact"
        method="POST"
        data-netlify="true"
+       action="/dummy_thanks.html"
      >
// 後略

dummy_thanks.htmlは実ファイルなので404になることはありません。そして<meta http-equiv="refresh" content="0;url=/thanks" />/thanksに遷移した場合はnetlify.tomlで設定したルートへのリダイレクトが効くため、React Routerで設定したサンクスページが正常に表示されるという仕組みです。

まとめ

以下の内容をご紹介しました。

  • Cognito認証やReact Routerを利用したReactのSPAをNetlifyにデプロイする方法
  • ReactでNetlifyフォームを利用する方法
  • React + Netlifyフォームでサンクスページを表示するための工夫

問い合わせフォームの実装では調査に思いがけず時間がかかってしまい、途中から自分で実装した方が早かったかもしれないと思ったりもしましたが、きっと次回からはスムーズに実装できるはずです。Netlifyは日本国内にCDNがないので若干遅いのが難点ですが、それ以外は非常に良いサービスだと思います。

Discussion