Rails 使いにおすすめ! Remix と Cloudflare でアプリケーションを作る!  

# はじめに
この記事はハンズオン形式で Remix と Cloudflare でアプリケーションを作成します。
**未経験からRails エンジニアになった方をメインのターゲットとしています。**
（私自身がその対象です）

Rails でアプリケーション開発ができるようになってきたけど、ほかの技術についても知ってみたいな、、、と思っている経験1年目から2年目くらいのジュニアエンジニアの方の力になりたいです。

Remix ってなに？、Cloudflare とか難しそう、、、と感じるかもしれません。
この記事では、**先に Remix や Typescript の勉強をする必要はありません。**
（Rails だったらこうですという例を入れます。）
まずはこの記事で動くアプリケーションを作成して、抵抗感をなくすことが肝要です。

興味をもったら下記をやってみれば良いのです。
* Remix と Rails について学ぶ
    * Remix Rails とググっていろんな記事を読んでみる

* Remix 公式ドキュメント チュートリアルをやる
    * https://remix.run/docs/en/main/start/tutorial

* TypeScript の勉強をする
    * https://www.udemy.com/course/typescript-complete/?couponCode=JUST4U02223

　

# 使用する技術
Remix, React, Typescript
Cloudflare pages, d1, Zero Trust

# 作成するアプリケーションの概要
管理画面的なものを想定しています。
![スクリーンショット 2024-09-27 15.22.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/96eb7f6e-cab1-65eb-3da8-5302fadc7bbc.png)

データ一覧、作成、編集削除の基本的なCRUDができるアプリケーションです。

ユーザーは簡易的な認証を経て、アプリケーションにアクセスします。
複数の入力フォームが存在する画面があります。
ユーザーはフォームを入力して、サブミットします。
送信されたデータを検証します。（バリデーション）
d1 にデータを保存します。
ユーザーに結果を返します。

# アプリケーションのセットアップ
https://developers.cloudflare.com/pages/framework-guides/deploy-a-remix-site/
↑の通りやっていきます。

```
npm create cloudflare@latest -- my-remix-app --framework=remix
```

インストール時に何度か質問がきます
全部 yes でOKです。
```
Need to install the following packages:
create-remix@2.11.1
Ok to proceed? (y) y

  git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes
         
Do you want to deploy your application?
         yes

```
インストールとデプロイが完了すると下記がブラウザで確認できます。

![スクリーンショット 2024-09-16 9.17.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/2763a658-a56a-2af9-fa63-e8fa3710937b.png)

これだけでデプロイまで完了しています、簡単ですね！


エディタを開いて、下記のコマンドを実行して開発環境が立ち上がることも確認しましょう。
```
npm run dev
```

# Git と連携
https://github.com/new/
↑にアクセスして新しいレポジトリを作成します。
Repository name は任意です。
Public か Private かも任意です。

レポジトリを作成したら下記を実行します。
```
git remote add origin https://github.com/<your-gh-username>/<repository-name>
git branch -M main
git push -u origin main
```
これでGit との連携が完了です。

# Cloudflare Pages と連携、デプロイ
まず、Cloudflare のアカウントを作成してください
「Cloudflare アカウント」と検索すればやり方が出てきます。
例：https://zenn.dev/taketakekaho/articles/5f72f4c58ab0ba

完了したらダッシュボードにアクセスします。
https://dash.cloudflare.com/

左のタブから 「Workers & Pages」を選択して、「作成」ボタンを押下します。
![スクリーンショット 2024-09-17 7.36.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/6c741db0-d232-02fa-d70b-910ce0f4a7a2.png)

Pages タブを選択して、Git リポジトリをインポートします。
![スクリーンショット 2024-09-17 7.37.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/b469e833-927b-9df0-3214-e43c1de8f88d.png)

先ほど作成したリポジトリを選択して、「セットアップの開始」を押下します。
セットアップではフレームワークのプリセットだけ変更して、「Remix」を選択します。
他はデフォルトで良いです。（後から変更できます。）
![スクリーンショット 2024-09-17 7.40.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/b4f1c131-817c-15c5-07dd-cde809608f4b.png)

「保存してデプロイする」を押下します。
![スクリーンショット 2024-09-17 7.42.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/60c52aa3-65e6-3edf-cd15-a31b5857ca6d.png)

成功しました。
![スクリーンショット 2024-09-17 7.43.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/8a22b32a-c5a0-8c5a-64c9-61c2a3271f6a.png)

# 設定ファイルのデプロイ
↑まで成功して生成されたURLにアクセスしてもサイトが表示されない場合は下記を試してください。

```
git status
```
設定ファイル系がGit に反映されていない場合があります。
全て反映しましょう。

```
git add .
git commit -m "add: settings"
git push origin main
```

main ブランチに PUSH すると自動的に Cloudflare Pages にデプロイされます。
Cloudflare Pages のダッシュボードで、デプロイが完了したことを確認します。
サイトに再度アクセスします。

# コードを変更して、デプロイしてみる
開発用にブランチを作成します。
```
git checkout -b develop
```

app/routes/_index.tsx を開きます
コードを少しだけ変えてみます
```tsx
          <p className="leading-6 text-gray-700 dark:text-gray-200">
            What&apos;s next your Remix app?
          </p>
```
`npm run dev` で立ち上げた開発環境で、以下のように変更が反映されていることを確認できます。

![スクリーンショット 2024-09-16 9.29.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/de96836d-1926-29ed-4d3e-05ff35dc268b.png)

develop に反映します。
 ```
 git add app/routes/_index.tsx 
 git commit -m "fix: top page text"
 git push origin develop
 ```

PRを作成すると、下記のように自動的にデプロイが始まります。
![スクリーンショット 2024-09-17 7.45.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/cea06969-d6e5-045e-77b4-637edc929c84.png)


これは、Cloudflare Pages のデフォルトの設定です。
main ブランチ以外のブランチへの PUSH を検知して、プレビュー環境にデプロイを実行してくれています。
![スクリーンショット 2024-09-18 7.25.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/1e852611-4442-e241-4094-5eb9346218fb.png)
プレビュー環境でも動作を確認できます。

 プレビュー環境へのデプロイは develop ブランチに限定しておきます。（これは任意の作業です）
「プレビュー環境のデプロイを構成する」を押下します。
![スクリーンショット 2024-09-18 7.26.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/35b70619-2698-8b56-a1b4-6c7e02bb8e7f.png)

develop を指定して保存します。
![スクリーンショット 2024-09-18 7.27.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/75e1843f-a087-2a98-a257-eb504eefe00c.png)

![スクリーンショット 2024-09-18 7.28.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/fb5db107-c625-6a72-c10d-bef714915a07.png)

PR に戻ります。
マージしましょう。
![スクリーンショット 2024-09-18 7.29.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/b356f446-30c5-1439-b195-c9fa86570f41.png)

Cloudflare ダッシュボードに行きます。
main ブランチへの Merge を検知して、本番環境へのデプロイが起動します。
![スクリーンショット 2024-09-18 7.30.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/de213e17-38c7-8670-f765-8a7e517b440d.png)

1〜2分で完了します。
本番環境を見てみます。
変更が反映されていますね。
![スクリーンショット 2024-09-18 7.31.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/664ed260-0c3e-400b-568f-8ddbc9167394.png)

# デプロイは他の方法も可能です
今回は PR ベースでのデプロイを紹介しました。
他にもデプロイする方法があります。
https://developers.cloudflare.com/pages/framework-guides/deploy-a-remix-site/#deploy-with-cloudflare-pages

# Cloudflare バインディング
バインディングとは、アプリケーションと Cloudflare の機能を紐づけることです。
https://developers.cloudflare.com/workers/wrangler/configuration/#bindings
バインディングにより、アプリケーションはKVネームスペース、Durable Objects、R2ストレージバケット、D1データベースなどのCloudflare開発者向け製品とやり取りできるようになります。

ここでは、アプリケーションとデータベースを紐づけます。
Remix（アプリケーション）と Cloudflare D1（データベース）を紐づけます。

# D1 データベースを作成する
https://developers.cloudflare.com/d1/get-started/
↑を参考に作成していきます。

コマンドを実行します。
```
npx wrangler d1 create prod-d1-tutorial
```
prod-d1-tutorial の部分は任意で、データベースの名前です。
下記の結果が返ってきます。
```shell
$ npx wrangler d1 create prod-d1-tutorial

 ⛅️ wrangler 3.57.1 (update available 3.78.5)
-------------------------------------------------------
✅ Successfully created DB 'prod-d1-tutorial' in region APAC
Created your new D1 database.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "prod-d1-tutorial"
database_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
wrangler.toml を開いて下記を貼り付けます。
```
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "prod-d1-tutorial"
database_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

これで D1 を作成できました。
ローカル環境のデータベースと、リモート環境のデータベースが作成されています。
Cloudflare のダッシュボードから、リモート環境のデータベースを確認できます。
![スクリーンショット 2024-09-19 7.34.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/1749002d-f570-e0df-6628-d46455afade6.png)
Workers & Pages のセクションに D1 があります。
これを押下するとデータベースを確認できます。


# D1 の型を生成
下記コマンドを実行します。
これは後で使う型を生成しています。
```
npm run typegen
```

# クエリを実行してみる
ファイルを作成します。
```
touch schema.sql
```
作成したファイルに下記を貼り付けます。
```sql
DROP TABLE IF EXISTS Customers;
CREATE TABLE IF NOT EXISTS Customers (CustomerId INTEGER PRIMARY KEY, CompanyName TEXT, ContactName TEXT);
INSERT INTO Customers (CustomerID, CompanyName, ContactName) VALUES (1, 'Alfreds Futterkiste', 'Maria Anders'), (4, 'Around the Horn', 'Thomas Hardy'), (11, 'Bs Beverages', 'Victoria Ashworth'), (13, 'Bs Beverages', 'Random Name');
```
下記のコマンドを実行します。
これは、ローカルの D1 に対して、↑の SQL を適用します。
（`--loacl` でローカル環境を指定しています）
```sql
npx wrangler d1 execute prod-d1-tutorial --local --file=./schema.sql
```
早速、作成したデータを参照してみます。
```sql
npx wrangler d1 execute prod-d1-tutorial --local --command="SELECT * FROM Customers"
```
下記のように結果が返ってきますね。
![スクリーンショット 2024-09-19 7.42.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/7bc9ee56-1668-61a3-2df8-9b4d90a85b8a.png)

# リモートの D1 にもクエリを実行する
`--remote` を指定すると、リモート環境に対して実行できます。

```
npx wrangler d1 execute prod-d1-tutorial --remote --file=./schema.sql
```
これでリモート環境の d1 にテーブルが作成されます。
あとから変更できるので、気楽に実行してみましょう。

下記は yes でOKです。
```
 This process may take some time, during which your D1 database will be unavailable to serve queries.
  Ok to proceed? … yes
```

Cloudflare ダッシュボードで、テーブルが作成されていることを確認できます。
![スクリーンショット 2024-09-20 7.49.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/2d5e2214-42ec-e2da-41ef-bcdd74092323.png)

下記コマンドでも確認できます。
```
npx wrangler d1 execute prod-d1-tutorial --remote --command="SELECT * FROM Customers"
```

ここまでの変更を develop ブランチにPUSHします。
PR を作成して、main ブランチにマージします。
これでデプロイも完了しますね。

# Cloudflare D1 と Pages のバインディングを確認する

https://developers.cloudflare.com/pages/functions/bindings/#d1-databases

Pages プロジェクトを選択 > 設定 > バインディング
 に移動します。
下記を確認します。
![スクリーンショット 2024-09-21 8.55.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/5809f75c-ae56-0b4f-ca5c-55f1389a9505.png)

プレビュー環境（develop ブランチと連動している環境）に切り替えて、バインディングを確認してください。
![スクリーンショット 2024-09-21 8.57.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/77631a3f-df0f-fa8a-5bd4-60d339ad8252.png)


# アプリケーションから D1 データベースに接続する
ここまでで、環境構築が完了です。

本題のアプリケーション開発に進みましょう。

```
touch app/routes/customers.tsx
```
Remix では、ルーティングをディレクトリとファイルの構成で行います。
ファイルシステムルーティングです。
詳細が気になる方は下記を参照ください。
https://remix.run/docs/en/main/discussion/routes
https://remix-docs-ja.techtalk.jp/discussion/routes

`app/routes/customers.tsx`はRails だと下記みたいなイメージです。
```ruby
# routes.rb
resources :customers, only: [:index]
# または
get '/customers', to: 'customers#index'
```

作成したファイルに下記のコードを貼り付けます。
```tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";

export async function loader({ context }: LoaderFunctionArgs) {
  const { env } = context.cloudflare;
  const customers = await env.DB.prepare("SELECT * FROM customers").all();

  if (!customers) {
    return json({ error: "No customers found" }, { status: 404 });
  }

  return json(customers);
}

export default function Index() {
  const customers = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Welcome to Remix</h1>
      <div>
        A value from D1:
        <p>{JSON.stringify(customers)}</p>
      </div>
    </div>
  );
}

```

D1 からデータを取得することができました！

# loader と action
Remix では loader と action を理解する必要があります。
詳しいことが知りたい場合は、下記ドキュメントを参照したり、
ググったり、チャットGPTに聞いたりすると良いでしょう。
https://remix.run/docs/en/main/discussion/data-flow

money forward さんの記事がわかりやすかったです。
https://tech.mfkessai.co.jp/2024/03/remix/

ざっくり知って、先に進みたい方はまず、下記の概念図を覚えてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/32b5159b-2782-817e-8b65-50f3a00da5f8.png)

`loader` と `action` は Rails でよく使われる `controller` アクションと似た役割を担っています。
### `loader` の説明
`loader` は、Rails の `show` や `index` アクションのように、クライアントにデータを提供するための関数です。
データの取得処理を行い、そのルート（ページ）に必要な情報をサーバー側で準備します。
Rails で言うところの「ビューに表示するデータをコントローラーで取得する」イメージです。
```ruby
def show
  @user = User.find(params[:id])
end
```

これが Remix の `loader` では以下のように表現されます。

```javascript
export let loader = async ({ params }) => {
  let user = await getUser(params.id);
  return { user };
};
```

そして、`@user` をビューで使うように、Remix では `useLoaderData` を使って `user` データを取得します。

```jsx
import { useLoaderData } from "remix";

export default function User() {
  let { user } = useLoaderData();
  return <div>{user.name}</div>;
}
```

### `action` の説明
`action` は、Rails の `create` や `update`, `destroy` アクションと同様に、サーバー側でデータを更新するための関数です。クライアントがフォームなどを使って送信したデータをサーバーで受け取り、その内容に基づいてデータベースを変更します。
```ruby
def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new
  end
end
```

Remix ではこの処理が `action` 関数として以下のように記述されます。

```javascript
export let action = async ({ request }) => {
  let formData = await request.formData();
  let name = formData.get("name");
  await createUser({ name });
  return redirect(`/users`);
};
```

そして、ユーザーがフォームを送信する際、次のような `Form` コンポーネントを使います。Rails の `form_with` や `form_for` に相当します。

```jsx
import { Form } from "remix";

export default function NewUser() {
  return (
    <Form method="post">
      <input type="text" name="name" />
      <button type="submit">Create User</button>
    </Form>
  );
}
```

### `loader` と `action` の違い
- `loader` は、Rails の `before_action` のようにリクエストに応じて必要なデータを取得し、クライアント側に渡すために使います。主に `GET` リクエストで使用され、ページの初期表示やデータの取得に使用されます。
- `action` は、クライアントから送信されたデータを使って、サーバー側でデータを作成・更新・削除する処理を行います。`POST`, `PATCH`, `PUT`, `DELETE` リクエストで使用され、Rails の `create`, `update`, `destroy` と同じ役割を持っています。

### データの同期
例えば、ユーザーがフォームを使ってデータを送信すると、Remix の `action` でデータの保存や更新が行われます。その後、自動的に `loader` が再度実行され、最新のデータを取得します。このデータはクライアントに渡され、React コンポーネント内の状態が更新されるので、Rails のフルページリロードなしで最新の状態が反映されます。

Rails で言うところの、`redirect_to` を使って更新後のページを再描画したり、`render` でそのまま同じページを再表示する代わりに、Remix ではこれが自動的に行われるのがポイントです。

# D1 からデータを取得する部分を`app/.server/*.ts`に実装する
まずは、ディレクトリとファイルを作成します。
```
mkdir app/.server && touch app/.server/database.ts
```
Remix では、`*.server.ts` はサーバーサイドでのみ動作するファイルになります。

型定義ファイルを作成します。
型は Rails 使いの方は見慣れないと思います。
変数やメソッド（関数）に特定の型を指定することで、さまざまな恩恵を受けます。（詳細は割愛）
```
mkdir app/types
touch app/types/customer.ts
```
app/types/customer.ts を下記にします。
```tsx
export type Customer = {
  CustomerId: number;
  CompanyName: string;
  ContactName: string;
};

```

app/routes/customers.tsx を下記に変更します。
loader で getCustomers 関数を呼び出すようにしました。
getCustomers は app/.server/database.ts に実装します。
```tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import { getCustomers } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
  const customers: Customer[] | null = await getCustomers(context);

  if (!customers || customers.length === 0) {
    throw new Response("No customers found", { status: 404 });
  }

  return json(customers);
}

export default function Index() {
  const customers = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Welcome to Remix</h1>
      <div>
        <h2>Customer List</h2>
        <table>
          <thead>
            <tr>
              <th>CustomerId</th>
              <th>CompanyName</th>
              <th>ContactName</th>
            </tr>
          </thead>
          <tbody>
            {customers.map((customer: Customer) => (
              <tr key={customer.CustomerId}>
                <td>{customer.CustomerId}</td>
                <td>{customer.CompanyName}</td>
                <td>{customer.ContactName}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
```

app/.server/database.ts
```tsx
import type { AppLoadContext } from "@remix-run/cloudflare";
import type { Customer } from "../types/customer";

export async function getCustomers(
  context: AppLoadContext
): Promise<Customer[] | null> {
  const env = context.cloudflare.env;
  const db = env.DB;

  const response = await db.prepare("SELECT * FROM customers").all();

  if (!response.success) {
    return null;
  }

  return response.results as Customer[];
}

```

これで、loader 関数内がすっきりしました。
データベースとやりとりするロジックを app/.server/database.ts に移動できました。

いまやったことを Rails に置き換えると下記みたいな感じです。
（all を使用するとモデルに書く理由がないので、あくまで例です）

```ruby
# controller
def index
  @customers = Customer.get_all_customers
end

# model
def self.get_all_customers
  all
end
```

# Customer を作成する
GET 系ができたので、POST 系をやります。

まず、Customer を作成する関数を実装します。

app/.server/database.ts
```tsx
export async function createCustomer(
  context: AppLoadContext,
  newCustomer: { CompanyName: string; ContactName: string }
) {
  const env = context.cloudflare.env;
  const db = env.DB;

  const response = await db
    .prepare(`INSERT INTO customers (CompanyName, ContactName) VALUES (?, ?)`)
    .bind(newCustomer.CompanyName, newCustomer.ContactName)
    .run();

  if (!response.success) {
    throw new Error("Failed to create customer");
  }

  return response.results;
}
```
次に、下記に action 関数を追加します。
action 関数は Remix の特徴の1つです。
https://remix.run/docs/en/main/route/action

formData からフォームの入力値を取得しています。
バリデーションをしています。（ここはあとで変えます。）
createCustomer でデータを作成します。

app/routes/customers.tsx　
```tsx
import type {
  LoaderFunctionArgs,
  ActionFunctionArgs,
} from "@remix-run/cloudflare";
import { json, redirect } from "@remix-run/cloudflare";
import { useLoaderData, Form } from "@remix-run/react";
import { getCustomers, createCustomer } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
// ここは変更なし
}

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const companyName = formData.get("CompanyName");
  const contactName = formData.get("ContactName");

  // このバリデーションはあとで変更
  if (typeof companyName !== "string" || typeof contactName !== "string") {
    return json({ error: "Invalid form submission" }, { status: 400 });
  }

  await createCustomer(context, {
    CompanyName: companyName,
    ContactName: contactName,
  });

  return redirect("/customers");
}
```

管理画面ぽい見た目に変えます。
app/routes/customers.tsx の全体です。

```tsx
import type {
  LoaderFunctionArgs,
  ActionFunctionArgs,
} from "@remix-run/cloudflare";
import { json, redirect } from "@remix-run/cloudflare";
import { useLoaderData, Form } from "@remix-run/react";
import { getCustomers, createCustomer } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
  const customers: Customer[] | null = await getCustomers(context);

  if (!customers || customers.length === 0) {
    throw new Response("No customers found", { status: 404 });
  }

  return json(customers);
}

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const companyName = formData.get("CompanyName");
  const contactName = formData.get("ContactName");

  // このバリデーションはあとで変更
  if (typeof companyName !== "string" || typeof contactName !== "string") {
    return json({ error: "Invalid form submission" }, { status: 400 });
  }

  await createCustomer(context, {
    CompanyName: companyName,
    ContactName: contactName,
  });

  return redirect("/customers");
}

export default function Index() {
  const customers = useLoaderData<typeof loader>();

  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-3xl font-bold mb-6 text-gray-700">ダッシュボード</h1>

      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">顧客一覧</h2>
        <table className="min-w-full bg-white border border-gray-300">
          <thead className="bg-gray-200">
            <tr>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CustomerId
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CompanyName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                ContactName
              </th>
            </tr>
          </thead>
          <tbody>
            {customers.map((customer: Customer) => (
              <tr key={customer.CustomerId} className="hover:bg-gray-100">
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CustomerId}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CompanyName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.ContactName}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="bg-white shadow-md rounded-lg p-6">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">
          顧客新規作成
        </h2>
        <Form method="post" className="space-y-4">
          <div>
            <label
              htmlFor="companyName"
              className="block text-sm font-medium text-gray-600 mb-1"
            >
              Company Name
            </label>
            <input
              type="text"
              id="companyName"
              name="CompanyName"
              required
              className="w-full px-4 py-2 border bg-inherit text-gray-500 border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <label
              htmlFor="contactName"
              className="block text-sm font-medium text-gray-600 mb-1"
            >
              Contact Name
            </label>
            <input
              type="text"
              id="contactName"
              name="ContactName"
              required
              className="w-full px-4 py-2 border bg-inherit text-gray-500 border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <button
            type="submit"
            className="bg-blue-500 text-white font-medium px-4 py-2 rounded-md hover:bg-blue-600 transition"
          >
            作成する
          </button>
        </Form>
      </div>
    </div>
  );
}
```

これで下記の見た目になり、新規作成もできるようになりました！
![スクリーンショット 2024-09-24 8.17.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/2d5d2b77-aa1d-2c38-da7d-bdde5e7bc961.png)

ここまでを GitHub に PUSH して、Cloudflare Pages にデプロイしておきましょう。

# Outlet を使用する
まずはファイルを作成します。
```
touch app/routes/customers._index.tsx
touch app/routes/customers.new.tsx
```
説明は後ほどします。
まずはコードを変更します。

app/routes/customers.tsx
```tsx
import { Outlet } from "@remix-run/react";

export default function CustomerLayout() {
  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-3xl font-bold mb-6 text-gray-700">ダッシュボード</h1>

      <Outlet />
    </div>
  );
}
```

app/routes/customers._index.tsx
```tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData, Link, Outlet } from "@remix-run/react";
import { getCustomers } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
  const customers: Customer[] | null = await getCustomers(context);

  if (!customers || customers.length === 0) {
    throw new Response("No customers found", { status: 404 });
  }

  return json(customers);
}

export default function CustomerIndex() {
  const customers = useLoaderData<typeof loader>();

  return (
    <>
      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">顧客一覧</h2>
        <Link to="/customers/new" className="text-blue-500 hover:underline">
          顧客新規作成
        </Link>

        <table className="min-w-full bg-white border border-gray-300">
          <thead className="bg-gray-200">
            <tr>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CustomerId
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CompanyName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                ContactName
              </th>
            </tr>
          </thead>

          <tbody>
            {customers.map((customer: Customer) => (
              <tr key={customer.CustomerId} className="hover:bg-gray-100">
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CustomerId}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CompanyName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.ContactName}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Outlet />
    </>
  );
}
```

app/routes/customers.new.tsx
```tsx
import type { ActionFunctionArgs } from "@remix-run/cloudflare";
import { json, redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { createCustomer } from "../.server/database";

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  const companyName = formData.get("CompanyName");
  const contactName = formData.get("ContactName");

  // このバリデーションはあとで変更
  if (typeof companyName !== "string" || typeof contactName !== "string") {
    return json({ error: "Invalid form submission" }, { status: 400 });
  }

  await createCustomer(context, {
    CompanyName: companyName,
    ContactName: contactName,
  });

  return redirect("/customers");
}

export default function CustomerNew() {
  return (
    <>
      <div className="bg-white shadow-md rounded-lg p-6">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">
          顧客新規作成
        </h2>
        <Form method="post" className="space-y-4">
          <div>
            <label
              htmlFor="companyName"
              className="block text-sm font-medium text-gray-600 mb-1"
            >
              Company Name
            </label>
            <input
              type="text"
              id="companyName"
              name="CompanyName"
              required
              className="w-full px-4 py-2 border bg-inherit text-gray-500 border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <div>
            <label
              htmlFor="contactName"
              className="block text-sm font-medium text-gray-600 mb-1"
            >
              Contact Name
            </label>
            <input
              type="text"
              id="contactName"
              name="ContactName"
              required
              className="w-full px-4 py-2 border bg-inherit text-gray-500 border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300"
            />
          </div>
          <button
            type="submit"
            className="bg-blue-500 text-white font-medium px-4 py-2 rounded-md hover:bg-blue-600 transition"
          >
            作成する
          </button>
        </Form>
      </div>
    </>
  );
}
```

app/routes/customers.tsx を3つのファイルに分解したような状態です。
これで、/customers にアクセスしてみます。
![スクリーンショット 2024-09-25 8.14.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/c7d3aa36-e679-db7b-9a96-cf9a668fd552.png)

「顧客新規作成」を押下すると、
![スクリーンショット 2024-09-25 8.15.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/83adcfd2-3f03-a7cd-2514-2d0fce3baa69.png)
「ダッシュボード」の文字以外の部分が変わりました。
パスは /customers/new になっていますね。

コードを見直します。
```tsx
import { Outlet } from "@remix-run/react";

export default function CustomerLayout() {
  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <h1 className="text-3xl font-bold mb-6 text-gray-700">ダッシュボード</h1>

      <Outlet />
    </div>
  );
}
```

app/routes/customers.tsx では、/customers 配下で共通の処理やレイアウト（スタイル）を記述します。
今回は、div と h1 が共通部分です。
`Outlet`を使用することで、customers.new.tsx や customers._index.tsx の内容をレンダリングすることができます。
`Outlet`とは、親ルート内において、子ルートがレンダリングされる場所を示すために使われるコンポーネントのことです。

`Outlet`について詳しく知りたい場合、公式のチュートリアルがおすすめです。
https://remix.run/docs/en/main/start/tutorial#nested-routes-and-outlets
こちらの記事もわかりやすいです。
https://zenn.dev/ak/articles/cef68c1b67a314#outlet

Rails では、yield と content_for が Remix の Outlet に相当します。

```ruby
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <title>MyApp</title>
  <%= yield :head %>  <!-- head にコンテンツを差し込むことができる -->
</head>
<body>
  <header>Header content here</header>
  <%= yield %>  <!-- メインコンテンツ -->
  <footer>Footer content here</footer>
</body>
</html>

<!-- app/views/pages/show.html.erb -->
<% content_for :head do %>
  <meta name="description" content="Page-specific description">
<% end %>

<h1>Page-specific content</h1>

```

後の項では、customers.edit.tsx を作成します。
このファイルも`Outlet`でレンダリングするようにします。

ここまでを GitHub に PUSH して、デプロイまで完了させましょう。

# バリデーションの実装
zod を使用してバリデーションを実装します。
https://github.com/colinhacks/zod
TypeScript向けのスキーマ宣言とデータ検証のためのライブラリです。
型安全な方法でデータ構造を定義し、それに基づいてデータを検証できます。
Rails でいつもやっている `valadates` の部分を zod を使用する感じになります。

基礎的な内容の解説
https://zenn.dev/fumito0808/articles/29ad3c1b51f8fe
```
npm install zod
```

まずは呼び出し部分。
さらにコンパクトになりましたね。
app/routes/customers.new.tsx
```tsx
import type { ActionFunctionArgs } from "@remix-run/cloudflare";
import { redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { createCustomer } from "../.server/database";

export async function action({ request, context }: ActionFunctionArgs) {
  const formData = await request.formData();
  await createCustomer(context, formData);

  return redirect("/customers");
}

// 以下略
```

CompanyName と ContactName に1文字以上、100文字以内で string 型のバリデーションを実装します。
app/.server/database.ts
```tsx
import type { AppLoadContext } from "@remix-run/cloudflare";
import type { Customer } from "../types/customer";
import { z } from "zod";

const CustomerSchema = z.object({
  CompanyName: z.string().min(1).max(100),
  ContactName: z.string().min(1).max(100),
});

export async function getCustomers(
// 略
}

export async function createCustomer(
  context: AppLoadContext,
  formData: FormData
) {
  const env = context.cloudflare.env;
  const db = env.DB;
  const formObject = {
    CompanyName: formData.get("CompanyName"),
    ContactName: formData.get("ContactName"),
  };

  const results = CustomerSchema.safeParse(formObject);
  if (!results.success) {
    throw new Error("Invalid form data");
  }

  const response = await db
    .prepare(`INSERT INTO customers (CompanyName, ContactName) VALUES (?, ?)`)
    .bind(results.data.CompanyName, results.data.ContactName)
    .run();

  if (!response.success) {
    throw new Error("Failed to create customer");
  }

  return response.results;
}
```
100文字以上入力するとエラー画面が表示されることを確認してみてください。
バリデーションの実装も簡単にできました。
zod の機能は他にもたくさんありますし、エラーハンドリングがお粗末ですが、今回はこのまま進みます。

なお、Rails だと下記のようなことをしています。
```ruby
class Customer < ApplicationRecord
  validates :CompanyName, presence: true, length: { minimum: 1, maximum: 100 }
  validates :ContactName, presence: true, length: { minimum: 1, maximum: 100 }
end
```
ここまでを GitHub に PUSH して、デプロイまで完了させましょう。

# 編集機能の作成
```
touch app/routes/customers.$customerId.edit.tsx
```
`$customerId`は Rails だと `:id`です。

$customerId で個別の顧客情報を取得する関数`getCustomerById`と
顧客情報を更新する関数`updateCustomer`を作成します。

`Rails` だと、edit/update アクションで下記をする部分です。
```ruby
def edit
  @customer = Customer.find(customer_params[:id])
end

def update
  @customer.update!(customer_params)
end
```

app/.server/database.ts
```tsx
export async function getCustomerById(
  context: AppLoadContext,
  customerId: number
): Promise<Customer | null> {
  const env = context.cloudflare.env;
  const db = env.DB;
  const response = await db
    .prepare("SELECT * FROM customers WHERE CustomerId = ?")
    .bind(customerId)
    .first();

  if (response === null) {
    return null;
  }

  return response as Customer;
}

export async function updateCustomer(
  context: AppLoadContext,
  customerId: number,
  formData: FormData
) {
  const env = context.cloudflare.env;
  const db = env.DB;

  const results = CustomerSchema.safeParse({
    CompanyName: formData.get("CompanyName"),
    ContactName: formData.get("ContactName"),
  });
  if (!results.success) {
    throw new Error("Invalid form data");
  }

  const response = await db
    .prepare(
      `UPDATE customers SET CompanyName = ?, ContactName = ? WHERE CustomerId = ?`
    )
    .bind(results.data.CompanyName, results.data.ContactName, customerId)
    .run();

  if (!response.success) {
    throw new Error("Failed to update customer");
  }

  return response.results;
}
```
編集ボタンを設置します。
app/routes/customers._index.tsx
```tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData, Link, Outlet } from "@remix-run/react";
import { getCustomers } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
  const customers: Customer[] | null = await getCustomers(context);

  if (!customers || customers.length === 0) {
    throw new Response("No customers found", { status: 404 });
  }

  return json(customers);
}

export default function CustomerIndex() {
  const customers = useLoaderData<typeof loader>();

  return (
    <>
      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">顧客一覧</h2>
        <Link to="/customers/new" className="text-blue-500 hover:underline">
          顧客新規作成
        </Link>

        <table className="min-w-full bg-white border border-gray-300">
          <thead className="bg-gray-200">
            <tr>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CustomerId
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CompanyName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                ContactName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b"></th>
            </tr>
          </thead>

          <tbody>
            {customers.map((customer: Customer) => (
              <tr key={customer.CustomerId} className="hover:bg-gray-100">
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CustomerId}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CompanyName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.ContactName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  <Link
                    to={`/customers/${customer.CustomerId}/edit`}
                    className="text-blue-500 hover:underline"
                  >
                    編集
                  </Link>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Outlet />
    </>
  );
}
```
編集用のページとフォームを作成します。
`Rails` だと `edit.html.erb` で `form_with` を使う部分です。

loader で顧客データを取得し、action で更新処理を行います。
app/routes/customers.$customerId.edit.tsx
```tsx
import { json, redirect } from "@remix-run/cloudflare";
import { useLoaderData, Form } from "@remix-run/react";
import { getCustomerById, updateCustomer } from "../.server/database";
import type {
  LoaderFunctionArgs,
  ActionFunctionArgs,
} from "@remix-run/cloudflare";
import type { Customer } from "../types/customer";

export async function loader({ params, context }: LoaderFunctionArgs) {
  const customerId = params.customerId;
  if (!customerId)
    throw new Response("Customer ID is required", { status: 400 });

  const customer: Customer | null = await getCustomerById(
    context,
    parseInt(customerId)
  );
  if (!customer) throw new Response("Customer not found", { status: 404 });

  return json(customer);
}

export async function action({ request, context, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const customerId = params.customerId;
  if (!customerId)
    throw new Response("Customer ID is required", { status: 400 });

  await updateCustomer(context, parseInt(customerId), formData);

  return redirect("/customers");
}

export default function EditCustomer() {
  const customer = useLoaderData<Customer>();

  return (
    <div className="bg-white shadow-md rounded-lg p-6">
      <h2 className="text-2xl font-semibold mb-4 text-gray-700">
        顧客情報編集
      </h2>
      <Form method="post" className="space-y-4">
        <div>
          <label
            className="block text-sm font-medium text-gray-600 mb-1"
            htmlFor="companyName"
          >
            Company Name
          </label>
          <input
            type="text"
            id="companyName"
            name="CompanyName"
            defaultValue={customer.CompanyName}
            required
            className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300 bg-inherit text-gray-500"
          />
        </div>
        <div>
          <label
            className="block text-sm font-medium text-gray-600 mb-1"
            htmlFor="contactName"
          >
            Contact Name
          </label>
          <input
            type="text"
            id="contactName"
            name="ContactName"
            defaultValue={customer.ContactName}
            required
            className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring focus:border-blue-300 bg-inherit text-gray-500"
          />
        </div>
        <button
          type="submit"
          className="bg-blue-500 text-white font-medium px-4 py-2 rounded-md hover:bg-blue-600 transition"
        >
          更新
        </button>
      </Form>
    </div>
  );
}
```

これで、各顧客の行に編集ボタンを追加できました。
そして、編集ページに遷移して、更新処理もできました。

ここまでを GitHub に PUSH して、デプロイまで完了させましょう。

# 削除機能の実装
```
touch app/routes/customers.$customerId.delete.tsx
```

顧客リストに「削除」ボタンを追加します。
```tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData, Link, Outlet } from "@remix-run/react";
import { getCustomers } from "../.server/database";
import type { Customer } from "../types/customer";

export async function loader({ context }: LoaderFunctionArgs) {
  const customers: Customer[] | null = await getCustomers(context);

  if (!customers || customers.length === 0) {
    throw new Response("No customers found", { status: 404 });
  }

  return json(customers);
}

export default function CustomerIndex() {
  const customers = useLoaderData<typeof loader>();

  return (
    <>
      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        <h2 className="text-2xl font-semibold mb-4 text-gray-700">顧客一覧</h2>
        <Link to="/customers/new" className="text-blue-500 hover:underline">
          顧客新規作成
        </Link>

        <table className="min-w-full bg-white border border-gray-300">
          <thead className="bg-gray-200">
            <tr>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CustomerId
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                CompanyName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b">
                ContactName
              </th>
              <th className="py-2 px-4 text-left font-medium text-gray-600 border-b"></th>
            </tr>
          </thead>

          <tbody>
            {customers.map((customer: Customer) => (
              <tr key={customer.CustomerId} className="hover:bg-gray-100">
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CustomerId}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.CompanyName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  {customer.ContactName}
                </td>
                <td className="py-2 px-4 border-b text-gray-500">
                  <Link
                    to={`/customers/${customer.CustomerId}/edit`}
                    className="text-blue-500 hover:underline"
                  >
                    編集
                  </Link>
                  <Link
                    to={`/customers/${customer.CustomerId}/delete`}
                    className="ml-4 text-red-500 hover:underline"
                  >
                    削除
                  </Link>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <Outlet />
    </>
  );
}
```
削除対象の顧客情報を表示して、確認メッセージと削除実行フォームを表示します。
`Rails` だと下記の部分に当たります。
```ruby
def destroy
  # before_action で @customer を設定
  @customer.destroy!
end
```

app/routes/customers.$customerId.delete.tsx
```tsx
import { json, redirect } from "@remix-run/cloudflare";
import { useLoaderData, Form } from "@remix-run/react";
import { getCustomerById, deleteCustomer } from "../.server/database";
import type {
  LoaderFunctionArgs,
  ActionFunctionArgs,
} from "@remix-run/cloudflare";
import type { Customer } from "../types/customer";

export async function loader({ params, context }: LoaderFunctionArgs) {
  const customerId = params.customerId;
  if (!customerId)
    throw new Response("Customer ID is required", { status: 400 });

  const customer: Customer | null = await getCustomerById(
    context,
    parseInt(customerId)
  );
  if (!customer) throw new Response("Customer not found", { status: 404 });

  return json(customer);
}

export async function action({ params, context }: ActionFunctionArgs) {
  const customerId = params.customerId;
  if (!customerId)
    throw new Response("Customer ID is required", { status: 400 });

  await deleteCustomer(context, parseInt(customerId));

  return redirect("/customers");
}

export default function DeleteCustomer() {
  const customer = useLoaderData<Customer>();

  return (
    <div className="bg-white shadow-md rounded-lg p-6">
      <h2 className="text-2xl font-semibold mb-4 text-gray-700">
        顧客情報の削除
      </h2>
      <p className="mb-4 text-gray-600">以下の顧客情報を本当に削除しますか？</p>
      <div className="mb-4 text-gray-600">
        <strong>Company Name:</strong> {customer.CompanyName}
      </div>
      <div className="mb-4 text-gray-600">
        <strong>Contact Name:</strong> {customer.ContactName}
      </div>
      <Form method="post">
        <button
          type="submit"
          className="bg-red-500 text-white font-medium px-4 py-2 rounded-md hover:bg-red-600 transition"
        >
          削除する
        </button>
      </Form>
    </div>
  );
}
```
データベースから顧客を削除するための `deleteCustomer` 関数を実装します。
app/.server/database.ts
```tsx
export async function deleteCustomer(
  context: AppLoadContext,
  customerId: number
) {
  const env = context.cloudflare.env;
  const db = env.DB;

  const response = await db
    .prepare(`DELETE FROM customers WHERE CustomerId = ?`)
    .bind(customerId)
    .run();

  if (!response.success) {
    throw new Error("Failed to delete customer");
  }

  return response.results;
}
```
これで、各顧客のデータを削除することができるようになりました。
ここまでを GitHub に PUSH して、デプロイまで完了させましょう。

# 簡易的な認証の実装
ここまでで、CRUD 機能を実装することができました。
最後に、簡易的な認証の実装をします。

まずは検証環境の認証を設定します。
下記の PR を作成したときの環境です。
![スクリーンショット 2024-09-27 8.29.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/aaf4638d-046e-6f0c-7407-3f63b2eef4a3.png)

ダッシュボードから、Pages アプリケーションを選択して、設定 → 一般に遷移します。
「有効にする」を押下します。
![スクリーンショット 2024-09-27 8.30.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/65277287-4cd8-6d98-0870-725343873f66.png)
これだけで完了です。

検証環境にアクセスすると下記になります。
![スクリーンショット 2024-09-27 8.34.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/4fd5dab3-3d4d-443b-7cd6-49bb040e3f8d.png)

Cloudflare に登録したメールアドレスでワンタイムパスワード認証をします。
メール内にあるワンタイムパスワードを使用するとアプリケーションにアクセスできます。

アクセスできるユーザーをメールアドレスやトークンなどで管理できます。
![スクリーンショット 2024-09-28 10.13.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/71275546-b8bd-7aa1-f8f6-2b4ee9a37f44.png)

本番環境にもアクセス制限をかけます。
ダッシュボードの下記を押下します。
![スクリーンショット 2024-09-27 8.09.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/423bd9f9-5d92-4a10-a061-2313463e1d39.png)
Zero Trust のダッシュボードに遷移したら、下記を押下します。
![スクリーンショット 2024-09-27 8.10.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/c9d8f533-6dce-7ca8-e21a-c42b80914b7a.png)

もし、Zero Trust のプランを選ぶ画面が出てきたら下記を実行してください。
Choose Plan を押下して Free を選択します。
支払い情報の入力をしてプランの設定が完了させます。
もし支払い情報を入力したくない場合、この項は読むだけでも良いです。
![スクリーンショット 2024-09-27 8.10.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/34c72f8d-5198-94dc-b0e5-2203fdb7f355.png)

次に、該当のアプリケーションの三点リーダを押下します。
Edit を押下します。
![スクリーンショット 2024-09-28 10.14.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/ac15d0ba-215d-4218-75a0-2c5ce18400b2.png)
Overview のタブに移動します。
Application domain に、下記のようにサブドメインを指定しないでアプリケーションのドメインを設定します。
![スクリーンショット 2024-09-28 10.18.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/b52a95f3-6d79-7c57-d2a5-b0186807bc70.png)
（サブドメインに * が設定されているルールは、検証環境用のアクセス制限です。）
これで、本番環境にもアクセス制限をすることができました。

アクセス許可したいメールアドレスの増減は下記から設定できます。
Configure を押下して、Configure rules の項で設定できます。
![スクリーンショット 2024-09-28 10.33.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916052/8f3d5eb8-7da7-4ef7-6f4b-b249a5139c30.png)

# さいごに
これで全ての工程が終了です！
基本的な CRUD 機能と簡易的な認証のあるアプリケーションが完成しました！

Cloudflare を使うことでインフラの設定を簡単にできましたね。
フルスタックフレームワークとしての Remix 入門することができました。

Remix に興味を持った方は使い慣れた Rails との違いを感じながら、
Remix の良さを調べていくと面白いかもしれません。

この記事をきっかけに、様々な技術に挑戦してもらえると嬉しいです！

反響があれば、このアプリケーションに機能追加する記事を作成します。