
クラスメソッドメンバーズポータルの社内ユーザー向けフロントエンドをリプレイスしているので技術スタックを紹介します
この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
AWS事業本部サービス開発室の金谷です。
クラスメソッドメンバーズポータルのフロントエンド開発に携わっています。
はじめに
弊社ではクラスメソッドメンバーズ(以下、メンバーズ)と呼ばれるAWSの利用費割引や請求代行、コンサルティング、セキュリティ、24/365サポートなどAWSに関することをおまかせしていただけるサービスを提供しています。
その中でメンバーズに契約いただいたお客様に対して、クラスメソッドメンバーズポータル(以下、CMP)というAWSアカウントリソースの利用状況を可視化できるWebサービスを提供しています。
現在、メンバーズを運用する社内ユーザー向けのフロントエンドをリプレイスしているので、その背景と採用している主な技術スタックについて紹介したいと思います。
ちなみにエンドユーザー向けフロントエンドのリプレイス記事もありますので、よろしければこちらもご参照ください。
リプレイスの背景
CMPは契約しているお客様向けに2013年頃から提供しているWebサービスになります。
弊チームでは上記の記事にもあるCMPの他、社内ユーザーが契約いただいたお客様へのサポート等を行うための管理画面も運用しています。
この管理画面もCMP同様老朽化してきており、継続的なアップデートや新機能の追加が難しい状況となっていました。
また、同じタイミングでユーザーIDの管理方法変更に伴う認証基盤の刷新や、バックエンドAPI側のリプレイスも必要となっていたため、これらへの対応も実施する必要が出てきました。
採用したフレームワーク・ライブラリ
以下に採用したフレームワーク・ライブラリを列挙します。
- フレームワーク
 - UI
- shadcn-ui(以下をラップしている)
 
 - 認証
 - スキーマ・バリデーション
 - テスト
 - コード生成
 - インフラ
 
基本はエンドユーザー向けの画面で選定したライブラリを使いつつ、App Routerを軸に選定しなおしている箇所がいくつかあります。
フレームワーク
Next.jsを採用し、App Routerを軸に設計しています。
個人的に関心がある技術だったこと、社内システムなので比較的チャレンジしやすいなどの理由が重なったこともありますが、RSCを用いることで責務の分離を行い、見通しの良い設計にできるのではないかという期待があったということで採用してみています。
サーバーコンポーネントとクライアントコンポーネントの棲み分けは以下のようにしています。
- サーバーコンポーネント
- クエリパラメータをZodを使ってバリデーションする
 - データフェッチを行い、結果を子コンポーネントへ渡す
 - エラーハンドリング
 
 - クライアントコンポーネント
- クライアントでのステート管理が必要な場合
 - ブラウザAPIの利用
 - なるべくクライアントコンポーネントの粒度は細かくする
 
 
可能な限りサーバー側で解決できるように、基本的にはuse clientの付与は最小限にしたいと思っています。
コンポーネントを分割している都合上、コードベース全てを紹介するのは難しいのですが、例えば以下のようなテーブルコンポーネントの場合、

こんな感じでコンポーネントを分割しています。(shadcn-uiがもともと提供しているクライアントコンポーネントも含んでいます。)
ユーザーインタラクションが発生するようなものは可能な限り小さな単位で分割し、それをサーバーコンポーネントから呼ぶような形にしています。

UI
shadcn-uiを採用してみました。
shadcn自体はライブラリではなくコマンドであり、コマンドを叩くことでRadix UIやTailwnd CSS、React Hook Formなどをラップしたコンポーネントを自動生成してくれるものです。
コピペで使えるコンポーネント集といった感じでしょうか。
安易にUIコンポーネントライブラリを採用してしまい、他のライブラリとの組み合わせやバージョンアップで苦労するという話はフロントエンドではよくあるので、個人的にはあまり大きなUIコンポーネントライブラリを採用したくない気持ちがありました。
一方でTailwindも含め、CSSをがっつり書くのはちょっと・・・というチーム全体の空気感もあったため、これらを総合して考えた結果、shadcn-uiにたどり着きました。
shadcn-uiはあくまでラッパーなので、何かあったとしても最終的にはTailwind CSSとRadix UIに分離して運用できます。
(もちろんTailwind CSSとRadix UIが廃れてしまったら元も子もないのですが・・・)
また、shadcn-uiはデフォルトでReact Hook Formを内包しているのですが、React Hook Form自体はクライアントの状態管理のみをスコープとしているため、Server Actionsとあまり相性がよろしくなかったです。
一応以下のようなハックを入れることで今のところは動作しています。
テスト
テストするスコープに応じてツールを使い分けています。
まず、非サーバーコンポーネントのテストはStorybookとTesting Libraryを使って実施しています。
非サーバーコンポーネントと書きましたが、例えば先に紹介したテーブルコンポーネントの場合は、use clientもuse serverも付与していないコンポーネントになります。
そのため(先の例では便宜上サーバーコンポーネントとしましたが)実際には親コンポーネントに応じてサーバーコンポーネントかクライアントコンポーネントかが変わります。
アプリケーションを動作させた場合はサーバーコンポーネントとして動作しますが、Storybook上でテストする場合は実質クライアントコンポーネント扱いにすることで従来からあるTesting Libraryでテストしやすくしています。
サーバーコンポーネントのテストについてはエラー系のみ書いています。
以下のようなイメージです。
import { vi } from "vitest";
import { ApiClient } from "@/lib/fetch";
import HogePage from "./page";
describe("hoge/[id]/page.tsx", () => {
  test("不正なIDの場合は notFound が発生する", async () => {
    expect(
      async () =>
        await HogePage({
          params: { id: "" },
          searchParams: {},
        }),
    ).rejects.toThrowError("NEXT_NOT_FOUND");
  });
  test("データが存在しない場合は notFound ", async () => {
    vi.spyOn(ApiClient.prototype, "get").mockImplementation(() =>
      Promise.resolve(null),
    );
    expect(async () => {
      await HogePage({
        params: { id: "1" },
        searchParams: {},
      });
    }).rejects.toThrowError("NEXT_NOT_FOUND");
  });
});
コード生成
複数のツールを採用しています。
まず、OpenAPIからのコード生成にはOpenAPI TypeScriptとOrvalを用いています。
openapi-zod-clientなどAPI ClientもZodのスキーマも自動生成できるものを採用しようと考えたのですが、App Routerだとfetchでもろもろ最適化が行われることから、fetchベースでクライアントを生成できるツールを選んだほうがよさそうだと考え、OpenAPI TypeScript(OpenAPI fetch)を採用しました。
(openapi-zod-clientはaxiosベース)
一方でこれだけだとZodのスキーマ生成まではできないので、それを補うためにOrvalも一緒に使うことにしました。
こんな感じのコマンドを叩いてクライントコード一式を生成できるようにしています。
{
  //...
  "scripts": {
    //...
    "openapi": "openapi-typescript https://api.example.com/openapi.json -o ./src/generated/schema.d.ts && orval --config ./openapi/zod.config.ts",
  },
  // ...
}
コンポーネントの生成にはScaffdogを使っています。
以下のようなテンプレートを用意しています。
---
name: "component"
description: "Generate React component."
root: "src"
output: "**/components"
ignore: ["src"]
questions:
  name: "enter component name"
  componentType:
    message: "select component type"
    choices:
      - "Flexible"
      - "Client Component"
      - "Server Component"
    initial: "Flexible"
---
# `{{ inputs.name | pascal }}/index.tsx`
```tsx
{{ if inputs.componentType == "Server Component" }}"use server";
import { getClient } from "@/lib/fetch";
import { {{ inputs.name | pascal }} as Presenter } from "./{{ inputs.name | pascal }}";
type Props = {
  query: any;
};
export async function {{ inputs.name | pascal }}({ query }: Props) {
  const client = await getClient();
  const data = await client.feature.doSomething({
    params: {
      query,
    },
  });
  return <Presenter data={data} />;
}{{ else }}export { {{ inputs.name | pascal }} } from "./{{ inputs.name | pascal }}";{{ end }}
```
# `{{ inputs.name | pascal }}/{{ inputs.name | pascal }}.tsx`
```tsx
{{ if inputs.componentType == "Client Component" }}"use client";{{ end }}export type Props = {
{{ if inputs.componentType == "Server Component" }}  data: any;{{ end }}
};
export function {{ inputs.name | pascal }}(props: Props) {
  return <div></div>;
}
```
# `{{ inputs.name | pascal }}/{{ inputs.name | pascal }}.stories.tsx`
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { {{ inputs.name | pascal }} } from "./{{ inputs.name | pascal }}";
const meta: Meta<typeof {{ inputs.name | pascal }}> = {
  component: {{ inputs.name | pascal }},
};
export default meta;
type Story = StoryObj<typeof {{ inputs.name | pascal }}>;
export const Default: Story = {
  // args: {}
};
若干複雑ではありますが、以下のようにFlexible(use clientもuse serverもない)、Client Component、Server Componentを選択し、それに応じたコンポーネントを作成できます。
今のところ明示的にServer Componentを選択することはあまりないので調整しても良いかもしれません。

おわりに
ここまで技術スタックや採用しているライブラリなどを紹介してきました。
App Routerを採用したことによる学習コストの高さはあるものの、見通しの良いコンポーネント設計ができているように感じています。
当フロントエンドはまだまだ絶賛開発中です。
今後も継続運用できるようにNext.jsも含めたライブラリ・フレームワークの動向をチェックしていきたいと思います。
参考
クラスメソッドメンバーズのバックエンドAPIをリプレイスしました
クラスメソッドメンバーズポータルのフロントエンドをリプレイスしたので技術スタックを紹介します







