Amazon Web Services ブログ

AWS Amplify Hosting のワイルドカードサブドメインをマルチテナントアプリで利用する

AWS Amplify Hosting における、Amplify アプリでカスタムドメインを使用する際のワイルドカードサブドメインの一般提供を発表することができ嬉しく思います。これは、Software as a Service (SaaS) やマルチテナントプラットフォームで、ユーザーにカスタマイズされた体験を提供する開発者にとって重要です。

この新機能は、静的アプリ、シングルページアプリケーション (SPA)、Next.js を使用したフルスタックサーバーサイドレンダリングアプリなど、カスタムドメインを使用して Amplify Hosting にデプロイされた任意のアプリで利用可能です。この機能により、動的なお客様固有のサブドメインを作成するプロセスが簡素化されるだけでなく、アプリのカスタマイズの可能性が広がります。ワイルドカードサブドメインでは、“*” ワイルドカードを使用して、トラフィックを Amplify アプリのブランチにルーティングする “catch-all” サブドメインを作成できます。これは、独自のユニークなサブドメイン識別子を必要とする SaaS アプリで一般的なパターンであり、お客様やアカウントのオンボーディング(およびオフボーディング)時にアプリが柔軟に対応できるようにします。

ソリューションの概要

この記事では、ワイルドカードサブドメインを活用した Next.js サーバーサイドレンダリング (SSR) アプリを Amplify Hosting で構築する方法を紹介します。Next.js ミドルウェアでサブドメイン識別子を取得するプロセスと、この機能をアプリのアーキテクチャに統合する方法を見ていきます。

具体的な例として、最小限の機能を持つ “Link in Bio” タイプのアプリを構築します。このアプリは Amplify の認証と GraphQL API を使って、個別のサブドメインルートとして即座にアクセス可能なユーザーアカウントを作成します。このアーキテクチャパターンは、ブログ、e コマース、カスタムウェブサイトプラットフォームなど、単一のコードベースで異なるサブドメインにわたって複数の顧客にサービスを提供する様々なマルチテナントアプリに拡張することができます。

ワイルドカードサブドメインを使った Next.js アプリのデプロイ

前提条件

ワイルドカードサブドメインを利用するために必要なものは以下の通りです。

  1. アプリに使用するカスタムドメイン
  2. このドメインの DNS 設定へのアクセス

まず、ワイルドカードサブドメインを利用した Next.js SSR アプリを AWS Amplify Hosting にデプロイします。このセットアップにより、アプリは動的に *.example.com のリクエストを処理できるようになります。アスタリスク (“*”) は、リクエストに含まれる有効な値のどれにもマッチします。

デプロイには GitHub を使うので、コードの変更を GitHub リポジトリにプッシュし、Amplify Hosting CI/CD を使ってアプリを作成し、リポジトリに接続してアプリをビルドします。

新しい Next.js アプリの作成

まず、create-next-app を使用してデフォルトの Next.js SSR アプリを作成します。このアプリは App Router を使用します。

npx create-next-app@latest with-wildcard-subdomains --app

参考までに、以下は package.json です。プロジェクトの構成は SSR 用にする必要があります。scripts セクションは以下のようにします。

{
  "name": "with-wildcard-subdomains",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "^14.0.1",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10",
    "eslint": "^8",
    "eslint-config-next": "13.5.6",
    "postcss": "^8",
    "tailwindcss": "^3",
    "typescript": "^5"
  }
}

GitHub に新しいリポジトリを作成し、新しく作成されたプロジェクトをそれにプッシュします。

Amplify Hosting で新しいアプリをデプロイする

Amplify Hosting の Host a web app フローを使用します。アプリを作成する際にいくつか設定します。

まず、GitHub アカウント内のアプリのリポジトリに接続します。接続すると、Amplify Hosting があなたのアカウントのリポジトリをフォークし、デプロイを確認するよう求めます。

Host a new web app and name it

アプリがサーバーサイドレンダリングデプロイメントとして認識されていることを確認します。ビルドコマンドは npm run buildbaseDirectory の値は .next になっている必要があります。

サーバーサイドレンダリングのログ用の IAM ロールを選択または作成します。これにより、Amazon CloudWatch にサーバーサイドのログを収集することができます。したがって、React Server Components や API ルート内の任意の console.log はあなたのアカウントにログ出力されます。

Build image settings セクションで、これが初めてのデプロイであればカスタムビルドイメージの値として amplify:al2023 を使用します。アプリがすでにデプロイされている場合は、ドロップダウンから Amazon Linux 2023 イメージを選択します。

Select the Amazon Linux 2023 build image

Next をクリックしてアプリをデプロイします。アプリはフルスタックの SSR アプリをビルドしてデプロイします。サーバーサイドのコンピュートランタイムのランタイム環境は、ビルド時に使用されたランタイムと一致します。

The Amplify Hosting CI/CD pipeline

CI/CD パイプラインが完了した後、アプリは amplifyapp.com ドメインでホストされます。次に、カスタムドメインを追加します。

ワイルドカードサブドメインの設定

カスタムドメインの設定

ナビゲーションペインで、App Settings > Domain management を選択します。カスタムドメインを追加し、設定します。

Configure a custom domain default

注: 例として、example.com をドメインとして使用しています。ここではあなたが所有している、あるいはDNS レコードの更新が可能であるドメインを使用してください。

Add a custom domain

ワイルドカードサブドメインを追加するには、Add をクリックし、値としてアスタリスク * を入力します。このシナリオでは、www.example.com へのリクエストはメインブランチにマッピングされます。さらに、他の任意のサブドメイン値もアスタリスクによってマッチされ、メインブランチへのトラフィックをマッピングします。

Add a wildcard subdomain to the custom domain

所有権を確認するために DNS を更新します。数分後、Amplify Hosting はドメインの SSL を作成し、設定します。

Custom domain SSL configuration

これが完了したら、設定したサブドメイン用にさらに 2 つの CNAME レコードを追加する必要があります。DNS で設定して完了すると、カスタムドメインのステータスは Available に切り替わります。

Successful custom domain configuration

ワイルドカードサブドメインが稼働しました!アプリはどのサブドメイン値でもアクセス可能です。ユーザーがアプリにアクセスすると、アプリのミドルウェアはアプリのインデックスページにルーティングします。デプロイ後、www 以外の任意のサブドメイン値にアクセスしてみてください。メインルートのインデックスページにリダイレクトされます。

上記の手順を踏むことで、Next.js アプリは動的なサブドメインを扱えるようになり、SaaS タイプのアプリに最適な形となります。

次のステップ – Link-in-Bio アプリの構築

ブランチをデプロイし、カスタムドメインとワイルドカードサブドメインを設定したので、最小限の機能を備えた “Link in Bio” アプリを作成する準備ができました。このアプリは、ユーザーが独自のユーザー名でサインアップし、サブドメイン(例: <username>.<domain>.com )経由で個人の Bio ページにアクセスし、動的にプロフィールにリダイレクトできるようにするものです。

サブドメインのパースとリクエストのリダイレクトを処理するために、Next.js のミドルウェアを使用します。この機能により、Next.js アプリ内でリクエストをインターセプトして変更することができます。さらに、Amplify JS とプリビルドの Amplify UI Authenticator コンポーネントを使用したユーザー認証を統合します。

これを実行するために、以下の手順に従います。

  1. Amplify CLI を使用して Amplify バックエンドを作成し、Next.js プロジェクトにプルする
  2. Amplify Auth を追加する
  3. データの永続化のための GraphQL API を追加する
  4. ユーザーがアプリにサインアップし、ダイレクトナビゲーション用のユーザー名を選択できるようにする
  5. サブドメインをキャプチャし、正しいページにリクエストを書き換えるためのミドルウェアを追加する

アプリの Get Started > Backend environments タブをクリックして、アプリの Amplify バックエンドを有効にします。Local setup instructions を使って、Amplify バックエンドをローカル環境にプルします。

amplify pull --appId <app-id> --envName staging

プロンプトが表示されたら、手順に従って Amplify Studio にログインし、ローカルのフロントエンドアプリを新しい Amplify バックエンドにリンクします。コマンドラインに戻った後、プロンプトの選択を行い、最後に次のように入力します。

Do you plan on modifying this backend? Yes

これで、アプリの構築を続けることができます。

アプリの構造

必要な機能を追加するために、App Router を使用する Next.js 14 アプリの構造を変更します。更新内容は以下の通りです。

  1. Amplify Authenticator/login ルートに統合し、ユーザー登録、ログイン、プロフィール更新を可能にする
  2. ユーザーが Bio プロフィール情報を更新できる BioForm コンポーネント
  3. /users/[username] ルートはリクエストを受けるルートになります。ユーザーのサブドメインが [username] に一致した場合、リクエストはここに向けられます。
  4. middleware.ts はリクエストのインターセプトと正しいパスへのマッピングを処理します
 .
+├── amplify/
 ├── app/
+│   ├─ (auth)/
+│   │     └─ login/
+│   │         ├─ BioForm.tsx
+│   │         └─ page.tsx
+│   │
+│   ├─ users/
+│   │   └─ [username]/
+│   │       └─ page.tsx     
 │   ├─ layout.tsx
 │   ├─ page.tsx
 │   └─ ...
+├── middleware.ts
 ├── node_modules/
 ├── public/
 ├── next.config.js 
 ... 
 └─ README.md

Amplify Auth の追加

Amplify Auth を追加します。サインインメカニズムとして username を選択します。

amplify add auth

GraphQL API の追加

ユーザーの概念ができたので、ユーザーが自分の Bio ページに表示するデータを永続化するための API を追加します。Bio の説明と Bio リンクを保持するための最小限のデータモデルを持つ GraphQL API を追加します。

amplify add api 

schema.graphql に User スキーマを追加します。これにより、ユーザーは自分のプロフィールレコードを作成、読み取り、更新することができると同時に、プロフィールへの公開アクセスも可能になります。

type User
  @model
  @auth(rules: [{ allow: public, operations: [read] }, { allow: owner }]) {
  id: ID!
  username: String! @index(name: "byUsername", queryField: "byUsername")
  description: String
  link: String
}

変更をプッシュします。これにより、以前の Auth の変更もプッシュされます。

amplify push

UI の作成

次に、Amplify JS と Amplify UI ライブラリを統合します。これにより、アプリは先ほどプロビジョニングされたクラウドリソースを使用できるようになります。ユーザー登録には <Authenticator> コンポーネントを使用します。さらに、Amplify JS ライブラリはミューテーションとクエリを通じて GraphQL API と連携します。

UI の構築を始めるために、以下の依存関係をインストールします。

  1. npm install aws-amplify@6
  2. npm install @aws-amplify/ui-react

認証の追加

デフォルトの Authenticator は、追加のフィールドレベル検証とともに使用されます。ユーザー名はサブドメイン参照にもなるため、subdomainRegex を使って検証されます。

"use client";

// ...imports...

export default function App() {
  return (
    <div>
      <Authenticator
        initialState="signUp"
        components={{
          SignUp: {
            FormFields() {
              return (
                <>
                  <Authenticator.SignUp.FormFields />
                </>
              );
            },
          },
        }}
        services={{
          async validateCustomSignUp(formData) {
            // this is important
            if (!formData.username) {
              return {
                acknowledgement: "Username is invalid.",
              };
            }

            // match subdomain pattern
            const subdomainRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

            // check if subdomain is valid
            if (!subdomainRegex.test(formData?.username)) {
              return {
                acknowledgement: "Username is invalid.",
              };
            }
          },
        }}
      >
        {({ signOut, user }) => (
          <div>
            <main>
              <h1>Hello, {user?.username}.</h1>
              <div>
                <button onClick={signOut}>Sign out</button>
              </div>
            </main>
          </div>
        )}
      </Authenticator>
    </div>
  );
}

新しいルートで Authenticator が設定されると、アカウントを作成する前やアプリでサインインする前に、以下のようなビューが app.<domain>/login ルートに表示されます。

New app login page

そして、ユーザー名でサインインした後は以下のようになります。(例: stephen

Authenticated user page

プロフィール Bio フォームの追加

ユーザーのBio ページで公開するために、ユーザーの説明とリンクフィールドを取得する簡単なフォームを追加します。

最初はユーザーのユーザー名のみ持っているため、このフィールドは GraphQL API で簡単に検索できるようにフォームにあらかじめ入力されています。GraphQL API はユーザー名にインデックスを持つことでこれを実現します。

新しいコンポーネント BioForm.tsx を作成し、既存のログインページに含めます。最小限のバージョンは以下のようになります。

// /app/(auth)/login/BioForm.tsx
"use client";

import { useEffect, useState } from "react";
import type { FormEvent } from "react";

import { Amplify } from "aws-amplify";
import { createUser, updateUser } from "@/src/graphql/mutations";
import * as queries from "@/src/graphql/queries";
import { getCurrentUser } from "@aws-amplify/auth";
import { generateClient } from "aws-amplify/api";

import awsExports from "@/src/aws-exports";

Amplify.configure(awsExports);

const client = generateClient();

export default function BioForm() {
  const [loggedInUser, setLoggedInUser] = useState<any>(null);
  const [userProfile, setUserProfile] = useState<any>(null);
  const [userProfileExists, setUserProfileExists] = useState<boolean>(false);
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const form = new FormData(event.currentTarget);
    const description = form.get("description") || userProfile?.description;
    const link = form.get("link") || userProfile?.link;

    if (userProfileExists) {
      await onUpdate({
        id: userProfile?.id,
        username: loggedInUser?.username,
        description,
        link,
      });
    } else {
      await onCreate({
        username: loggedInUser?.username,
        description,
        link,
      });
    }
  }

  // include mutation handlers to update user profile
  const onCreate = async (formData: {
    username: string;
    description: string;
    link: string;
  }) => {
    const { username, description, link } = formData;
    const input = { username, description, link };
    await client.graphql({
      query: createUser,
      variables: { input },
      authMode: "userPool",
    });
  };

  const onUpdate = async (formData: {
    id: string;
    username: string;
    description: string;
    link: string;
  }) => {
    const { id, username, description, link } = formData;
    const input = { id, username, description, link };
    await client.graphql({
      query: updateUser,
      variables: { input },
      authMode: "userPool",
    });
  };

  const getUserProfile = async (username: string) => {
    return await client.graphql({
      query: queries.byUsername,
      variables: { username },
      authMode: "userPool",
    });
  };

  useEffect(() => {
    const getUser = async () => {
      const user = await getCurrentUser();
      if (user) {
        setLoggedInUser(user as any);

        const { data } = await getUserProfile(user?.username);

        const profile = data?.byUsername?.items[0];

        if (profile) {
          setUserProfileExists(true);
          setUserProfile(profile as any);
        }
      }
    };

    getUser();
  }, []);

  return (
    <form onSubmit={onSubmit}>
      <div>
        <input
          type="text"
          name="username"
          id="username"
          value={loggedInUser?.username}
          disabled={true}
        />
      </div>

      <div>
        <textarea name="description" id="description" />
      </div>

      <div>
        <label htmlFor="link">Link</label>
        <input type="url" name="link" id="link" />
      </div>

      <div>
        <button type="submit">
          {userProfileExists ? "Update" : "Create"}
        </button>
      </div>
    </form>
  );
}

現在のログインページを更新して、新しいコンポーネントを含めます。これで、ユーザーがログインすると、プロフィールフォームが表示されます。

User Bio Profile form

Link-in-Bio ページの作成

エンドユーザーがユーザー名サブドメインのルートにアクセスすると、リクエストは /users ルートに書き換えられます。ユーザー名は、ユーザーのプロフィールを取得して表示するための動的パラメーターとして機能します。この機能を実装するために、app/users/[username]/page.tsx でユーザーの Bio プロフィール情報を表示する公開ページを作成します。Next.js のルートパラメーターを使用して、Amplify JS でユーザーのプロフィールをクエリできます。以下は例です。

const { data } = await client.graphql({
  query: queries.byUsername,
  variables: { username },
})

ミドルウェアによるマルチテナントルーティング

これは、サブドメインでマッチングし、リクエストを正しいアプリページに書き換えるための middleware.ts ハンドラの例です。リクエストは、サブドメイン値を含む場合には適切なユーザープロフィールページにルーティングされ、ログインページは app. サブドメインで提供されます。

import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: [
    /*
     * Match all paths except for:
     * 1. /api routes
     * 2. /_next (Next.js internals)
     * 3. /_static (inside /public)
     * 4. all root files inside /public
     */
    "/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)",
  ],
};

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl;

  const hostname = req.headers.get("host")!;

  const path = url.pathname;

  let subdomain = hostname.split(".")[0];

  subdomain = subdomain.replace("localhost:3000", "");

  // handle no subdomain or www with base path
  if ((subdomain === "www" || subdomain === "") && path === "/") {
    return NextResponse.rewrite(new URL("/", req.url));
  }

  // profile login
  if (subdomain === "app" && path === "/login") {
    return NextResponse.rewrite(new URL("/login", req.url));
  }

  // subdomains
  if (subdomain !== "app") {
    return NextResponse.rewrite(
      new URL(`/users/${subdomain}${path === "/" ? "" : path}`, req.url)
    );
  }

  return NextResponse.next();
}

これで、アプリがリクエストを受け取ると、ミドルウェアは以下を行います。

  1. パスに “app” のサブドメインと /login のパスがあるかどうかを確認する
  2. “app” に一致しないサブドメインをチェックする
  3. または、元のリクエストをそのまま渡すようにフォールバックする

これで、ユーザーが作成された後、ユーザープロフィールページが稼働します!例えば、stephen.localhost:3000 にアクセスすると、ユーザーが存在する場合にはプロフィールページが表示されます。

Hosting へのプッシュとデプロイ

更新されたアプリをデプロイするために、フロントエンドとバックエンドの両方の変更を含めて、まず Git リポジトリに変更をプッシュします。アプリに設定された IAM サービスロールを更新して、Amplify バックエンドのビルドを許可する必要があります。これは App settings > General の設定で更新できます。

それが完了したら、フロントエンドをバックエンドにリンクして、継続的デプロイメントを有効にします。

Link Amplify frontend to the new staging backend

App settings > Build image settings で、Amplify CLI のバージョンに適した Live Package Updates を選択します。

これで、コミットをプッシュすると、フロントエンドとバックエンドがデプロイされます。

クリーンアップ

最後に、不要になったアプリを削除します。これを行うには、このアプリの Amplify Hosting Console の App settings > General に移動し、Delete app をクリックします。また、別のアプリに再利用する場合は、ドメインから DNS レコードを削除してください。

まとめ

ワイルドカードサブドメインを使用することは、Amplify Hosting 上でマルチテナントアーキテクチャを拡張する効果的な方法です。動的なアプリや SSR アプリにワイルドカードサブドメインの設定を統合することで、SaaS アプリの拡大に合わせて、最小限の設定でカスタマイズされた体験を提供することができます。

詳細については、Amplify Hosting のドキュメントの Wildcard subdomains をご覧ください。

本記事は、Wildcard Subdomains for Multi-tenant Apps on AWS Amplify Hosting を翻訳したものです。翻訳はソリューションアーキテクトの都築が担当しました。