Open9

Next.js App Router with AWS Amplify JavaScript Library v6

Gen TamuraGen Tamura

Amplify で Next.js の App Router を利用できる。以下を試すためのメモ。
https://aws.amazon.com/jp/blogs/news/amplify-javascript-v6/

概要

  • Next.js 14 にて App Router を利用
  • Amplify 経由で Auth と API (GraphQL + DynamoDB) を構築し、 App Router で Auth と DB にアクセスする

流れ

  • Next.js のプロジェクト作成
  • Amplify のセットアップ
  • Amplify Backend の準備
  • Amplify Backend に Auth と API を追加
  • Next.js を Amplify Hosting に deploy
  • App Router と Amplify JavaScript Library v6 を接続する
Gen TamuraGen Tamura

Amplify のセットアップ

(AWSのアカウントがない場合は作成する)

ライブラリを追加

npm install -g @aws-amplify/cli

Amplify の設定を実行

amplify configure

リージョンを選択し、AWSコンソールのログインでブラウザが開くので、以下のdocsを参考に、必要に応じ、新たなIAMを作成します。

例ではユーザを amplify-dev で、ポリシーを AdministratorAccess-Amplify で作成しています。

ユーザを作成したら、アクセストークンを発行します。
amplify-dev をクリックし、「セキュリティ認証情報」のタブをクリックし、「アクセストークン」のセクションからアクセスキーを発行します。

ターミナルに戻り、Enterを押すと、アクセスキーとシークレットキーを求められるので、コピペして入力します。
AWS Profile を求められるので、利用している端末で初めて設定するのであれば「default」、もしすでにdefaultが設定してある場合は「amplify-dev」など、わかりやすい名称が良いと思います。

設定が終了すると以下に設定が追加されます。(アクセスキー等は第三者に漏れないように気をつけましょう)

cat ~/.aws/profile
cat ~/.aws/credentials

https://docs.amplify.aws/javascript/start/getting-started/installation/

Gen TamuraGen Tamura

Amplify Backend の準備

まずは Amplify Backend の準備をします

amplify init

各種設定が求められるので、必要に応じて選択します。(お試しであれば、基本デフォルトでOKです。)
AWS Profile だけ default でない場合は、適切な AWS Profile を選んでください。

上記設定が終わると、以下が追加されます

  • プロジェクトルートに amplify ディレクトリが追加される
    • amplify に関連するファイル郡
  • src ディレクトリに amplifyconfiguration.jsonaws-exports.js ファイルが追加される
    • frontend アプリケーションから amplify backend にアクセスするためのファイル
    • これら2つのファイルは自動更新されるため、直接編集しない。また .gitignore の対象なので、取り扱いに気をつける

次に、アプリケーションで利用するライブラリを追加します。

npm install aws-amplify @aws-amplify/adapter-nextjs

これで Amplify Backendの準備が整いました。

https://docs.amplify.aws/javascript/start/project-setup/create-application/#create-a-new-amplify-backend

Next.js の server-side runtimes で Amplify Backend を利用する

サーバサイドで Amplify Backend を利用するために以下を追加する。(createServerRunner は一度だけ呼び出すので、RSCで呼び出すなら force-dynamic で毎回キャッシュなしのフルレンダリング、または middleware or API Routeでの利用になる)

utils/amplifyServerUtils.ts
import { createServerRunner } from '@aws-amplify/adapter-nextjs';
import config from '@/amplifyconfiguration.json';

export const { runWithAmplifyServerContext } = createServerRunner({
  config
});

React Server Components で利用するには、例えば、以下のように、 ログイン済みのユーザデータを表示します。

src/components/AuthGetCurrentUserServer.tsx
import { cookies } from 'next/headers';
import { getCurrentUser } from '@aws-amplify/auth/server';
import { runWithAmplifyServerContext } from '@/utils/amplifyServerUtils';

// NOTE: 公式ドキュメントで定義されていないコンポーネントだったので、仮に追加
const AuthFetchResult = ({ description, data }) => {
  return (
    <div>
      <p>{description}</p>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

// This page always dynamically renders per request
export const dynamic = 'force-dynamic';

export default async function AuthGetCurrentUserServer() {
  try {
    const currentUser = await runWithAmplifyServerContext({
      nextServerContext: { cookies },
      operation: (contextSpec) => getCurrentUser(contextSpec),
    });

    return (
      <AuthFetchResult
        description="The API is called on the server side."
        data={currentUser}
      />
    );
  } catch (error) {
    console.error(error);
    return <p>Something went wrong...</p>;
  }
}

middleware では以下のとおりです。(middleware は 2023/11/18現在 Auth のみで利用できるようです。

src/middleware.ts
import { fetchAuthSession } from 'aws-amplify/auth/server';
import { NextRequest, NextResponse } from 'next/server';
import { runWithAmplifyServerContext } from '@/utils/amplifyServerUtils';

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  const authenticated = await runWithAmplifyServerContext({
    nextServerContext: { request, response },
    operation: async (contextSpec) => {
      try {
        const session = await fetchAuthSession(contextSpec);
        return session.tokens !== undefined;
      } catch (error) {
        console.log(error);
        return false;
      }
    },
  });

  if (authenticated) {
    return response;
  }

  return NextResponse.redirect(new URL('/sign-in', request.url));
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico|sign-in).*)',
  ],
};

client component から Amplify Backend を利用するため、以下を追加する。

src/components/ConfigureAmplifyClientSide.ts
'use client';

import { Amplify } from 'aws-amplify';
import config from '../amplifyconfiguration.json';

Amplify.configure(config, { ssr: true });

export default function ConfigureAmplifyClientSide() {
  return null;
}

Amplify.configure は一度だけ呼び出したいので、ルートレイアウトに設置します。

src/app/layout.tsx
import ConfigureAmplifyClientSide from '@/components/ConfigureAmplifyClientSide';
import './globals.css';

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="container pb-6">
        <>
          <ConfigureAmplifyClientSide />
          {children}
        </>
      </body>
    </html>
  );
}

上記で、client component でも Amplify Library を参照することができます。

https://docs.amplify.aws/nextjs/build-a-backend/server-side-rendering/?platform=javascript

Gen TamuraGen Tamura

Amplify Backend に Auth を追加

続いて、具体的な Amplify Backend を追加していきます。

まずは auth から。以下で Amplify Backend に Auth を追加します。

amplify add auth

? Do you want to use the default authentication and security configuration? Default configuration
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings?  No, I am done.

Auth については、 GET系の fetchAuthSession, fetchUserAttributes, getCurrentUser のみ React Server Components で利用できるため、 GET 以外の HTTP リクエストメソッドは Client Component から実行する必要があります。

以下のように Client 側として、Amplify を設定を読み込みます。

src/components/ConfigureAmplifyClientSide.ts
'use client';

import { Amplify } from 'aws-amplify';
import config from '@/amplifyconfiguration.json';

Amplify.configure(config, { ssr: true });

export default function ConfigureAmplifyClientSide() {
  return null;
}

次に、Amplify の UI ライブラリを入れます。

npm install @aws-amplify/ui-react

新規登録やログインについては Client Component での利用が必要です。
以下のように Amplify UI で提供している UI を hooks で利用する一例です。

まずは Client Componentとして Provider を実装し、

src/components/AuthProvider.tsx
'use client';

import { Authenticator, View } from '@aws-amplify/ui-react';

export default function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Authenticator.Provider>
      <View>{children}</View>
    </Authenticator.Provider>
  );
}

ルートレイアウトにマウントします。

src/app/layout.tsx
import AuthProvider from '@/components/AuthProvider';
import ConfigureAmplifyClientSide from '@/components/ConfigureAmplifyClientSide';

export const dynamic = 'force-dynamic';

const RootLayout = async ({
  children,
}: {
  children: React.ReactNode;
  auth: React.ReactNode;
  unauth: React.ReactNode;
}) => {
  return (
    <html lang="en">
      <head>
        <link rel="icon" href="/favicon.png" />
      </head>
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
      <ConfigureAmplifyClientSide />
    </html>
  );
};
export default RootLayout;

https://ui.docs.amplify.aws/react/connected-components/authenticator/advanced#authenticator-provider

続いて、 hook で参照するコンポーネントを作成し、 page で呼び出します。

src/components/AuthenticatorClient.tsx
'use client';

import { Authenticator, useAuthenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

export default function AuthenticatorClient() {
  const { route, user, signOut } = useAuthenticator();

  return route === 'authenticated' ? (
    <main>
      <h1>Hello {user?.username}</h1>

      <pre>
        <code>{JSON.stringify(user, null, 2)}</code>
      </pre>

      <button onClick={signOut}>Sign out</button>
    </main>
  ) : (
    <Authenticator />
  );
}
src/app/page.tsx
import AuthenticatorClient from '@/components/AuthenticatorClient';

export default async function Home() {
  return (
    <main>
      <h1>Hello, Next.js App Router with AWS Amplify JavaScript Library v6!</h1>

      <AuthenticatorClient />
    </main>
  );
}

https://ui.docs.amplify.aws/react/connected-components/authenticator/advanced#useauthenticator-hook

Gen TamuraGen Tamura

Amplify Backend に API を追加

https://docs.amplify.aws/javascript/build-a-backend/graphqlapi/set-up-graphql-api/

続いて、Amplify Backend に API を追加します。

amplify add api

Amplify の API では GraphQL と REST API を選択できますが、ここでは GraphQL を選択します。
その他は以下のように選択します。(この GraphQL を選択した時点で TypeScript を選べるかもしれない)

? Select from one of the below mentioned services:
> GraphQL
? Here is the GraphQL API that we will create. Select a setting to edit or continue
> Continue
? Choose a schema template:
> Single object with fields (e.g., “Todo” with ID, name, description)
...
Edit your schema at <...>/schema.graphql or place .graphql files in a directory at <...>/schema
✔ Do you want to edit the schema now? (Y/n)
> yes
Edit the file in your editor: <...>/schema.graphql
✅ Successfully added resource new locally

上記の Single object with fileds の template を利用すると、認証なしのフルアクセスでスキーマが生成されるので、取り扱いにご注意ください。( ℹ️ デフォルトのまま進めると、 API キーの有効期限が7日間で設定されているので、有効期限を過ぎると、「 UnauthorizedException: Unknown error at buildRestApiServiceError」となり、アクセスできなくなりますので、こちらもご注意を。API キーが失効した場合は amplify update api で更新します。)

amplify/backend/api/project-name/schema.graphql
# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo @model {
  id: ID!
  name: String!
  description: String
}

上記設定ですと JavaScript で GraphQLを利用することになるので TypeScript に変更します。typescript を選択後、すべてデフォルトを選択しました。

amplify configure codegen

? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Enter the file name for the generated code src/API.ts
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
Codegen configured. Remember to run "amplify codegen" to generate your types and statements.

以下で型定義やクエリなどのコードが生成されます。

amplify codegen

✔ Downloaded the schema
✔ Generated GraphQL operations successfully and saved at src/graphql
✔ Code generated successfully and saved in file src/API.ts

Amplify Backend に反映します

amplify push

Next.js の server-side runtimes で GraphQL で利用できるように設定します。

https://docs.amplify.aws/javascript/build-a-backend/graphqlapi/connect-from-server-runtime/#step-1---choose-the-correct-graphql-api-client-for-nextjs-server-runtimes

src/utils/amplifyServerUtils.ts
import { createServerRunner } from '@aws-amplify/adapter-nextjs';
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api';
import config from '@/amplifyconfiguration.json';
import { cookies } from 'next/headers';

export const { runWithAmplifyServerContext } = createServerRunner({
  config,
});

export const cookieBasedClient = generateServerClientUsingCookies({
  config,
  cookies,
});
Gen TamuraGen Tamura

App Router と Amplify JavaScript Library v6 を接続する

何を伝えたいかというと、 Server Component も Client Component も Amplify のライブラリを利用できるようにしよう。ということ。

  • 基本は RSC を利用して、Interactive なところは CC を利用する
  • Auth の認証判定は server-side runtimes のものは middleware で対応する。 client は useAuthenticator で対応。( Parallel Routes で実装すると、より server-side に寄せられそうだが、今回は実装していない)
  • / がトップページ、 /todos がログイン後のページ

これまでのコードと重複あるが、以下実装例。(私は root path alias を ~ にしていますが、デフォルトでは @ が設定されているので、注意してください)

コンポーネントの naming などはサンプルコードなどからも参照しているので、あまり一貫性はないので、ご了承ください 🙏

src/app/layout.tsx
import AuthProvider from '~/components/AuthProvider';
import ConfigureAmplifyClientSide from '~/components/ConfigureAmplifyClientSide';

export const dynamic = 'force-dynamic';

const RootLayout = async ({ children }: { children: React.ReactNode }) => {
  return (
    <html lang="en">
      <head>
        <link rel="icon" href="/favicon.png" />
      </head>
      <body>
        <ConfigureAmplifyClientSide />
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
};

export default RootLayout;

ConfigureAmplifyClientSide は既出で、 Amplify Backend にアクセスするための初期設定を呼び出す。
AuthProvider は client side で Authenticator を利用するための Context Provider。

src/app/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getAuthenticated } from '~/utils/amplifyServerUtils';
import AuthenticatorClient from '~/components/AuthenticatorClient';

export default async function Home() {
  const authenticated = await getAuthenticated({ cookies });

  if (authenticated) {
    redirect('/todos');
  }

  return (
    <main>
      <h1>Hello, Next.js App Router with AWS Amplify JavaScript Library v6!</h1>

      <AuthenticatorClient />
    </main>
  );
}

RSC でログイン済みであれば、/todos にリダイレクトする。
未ログインであれば、新規登録とログインのタブのいずれかを選択できる Amplify UI の Authenticator コンポーネント が表示される。

src/app/todos/page.tsx
import AuthGetCurrentUserServer from '~/components/AuthGetCurrentUserServer';
import SignOutButton from '~/components/SignOutButton';
import TodosServer from '~/components/TodosServer';

export default async function Todos() {
  return (
    <main>
      <AuthGetCurrentUserServer />
      <SignOutButton />
      <TodosServer />
    </main>
  );
}

ログイン後のページ。デモなので、単純に表示しています。
(SC に Server の suffix なら、 CC に Client の suffix があるほうが一貫性がありますが、そもそも suffix をるけるのかは悩ましいところ。)

以下はコンポーネント群。

src/components/AuthGetCurrentUserServer.tsx
import { cookies } from 'next/headers';
import { getCurrentUser } from '@aws-amplify/auth/server';
import { runWithAmplifyServerContext } from '~/utils/amplifyServerUtils';

// This component is used to render the result of the API call
const AuthFetchResult = ({ description, data }: any) => {
  return (
    <div>
      <p>{description}</p>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

// This page always dynamically renders per request
export const dynamic = 'force-dynamic';

export default async function AuthGetCurrentUserServer() {
  try {
    const currentUser = await runWithAmplifyServerContext({
      nextServerContext: { cookies },
      operation: (contextSpec) => getCurrentUser(contextSpec),
    });

    return (
      <AuthFetchResult
        description="The API is called on the server side."
        data={currentUser}
      />
    );
  } catch (error) {
    console.error(error);
  }
}

これはドキュメントそのままです。 RSC として、 amplify の context から currentUser を取得しています。
https://docs.amplify.aws/nextjs/build-a-backend/server-side-rendering/?platform=javascript#with-nextjs-app-router

src/components/AuthenticatorClient.tsx
'use client';

import { Authenticator } from '@aws-amplify/ui-react';
import useAuthRedirect from '~/components/useAuthRedirect';
import '@aws-amplify/ui-react/styles.css';

export default function AuthenticatorClient() {
  useAuthRedirect({
    authhStatus: 'authenticated',
    redirectPath: '/todos',
  });

  return <Authenticator />;
}

Authのセクションからリダイレクト部分を useAuthRedirect で切り出しています。

src/components/useAuthRedirect.ts
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthenticator } from '@aws-amplify/ui-react';

type AuthStatus = ReturnType<typeof useAuthenticator>['authStatus'];

export default function useAuthRedirect(args: {
  authhStatus: AuthStatus;
  redirectPath: string;
}) {
  const router = useRouter();
  const { authStatus } = useAuthenticator();

  useEffect(() => {
    if (authStatus === args.authhStatus) {
      router.push(args.redirectPath);
      router.refresh();
    }
  }, [router, authStatus, args.authhStatus, args.redirectPath]);
}

CC のリダイレクトをハンドリングする hooks です。

src/components/SignOutButton.tsx
'use client';

import { useAuthenticator } from '@aws-amplify/ui-react';
import useAuthRedirect from '~/components/useAuthRedirect';

export default function SignOutButton() {
  useAuthRedirect({
    authhStatus: 'unauthenticated',
    redirectPath: '/',
  });

  const { signOut } = useAuthenticator();

  const handleClick = () => {
    signOut();
  };

  return <button onClick={handleClick}>Sign Out</button>;
}

サインアウトは CC で実行し、サインアウト後のリダイレクトも Client で実行するため、 useAuthRedirect でハンドリングしています。

Todo の表示は RSC で、onClick や Submit が必要なものは CC です。
List はRSC で DynamoDB から GraphQL でデータを取得してレンダリングしています。

src/components/TodosServer.tsx
import { cookieBasedClient } from '~/utils/amplifyServerUtils';
import { listTodos } from '~/graphql/queries';
import TodoNew from '~/components/TodoNew';
import Todo from '~/components/Todo';

export default async function TodosServer() {
  const { data, errors } = await cookieBasedClient.graphql({
    query: listTodos,
  });

  if (errors) {
    console.log('errors', errors);
  }

  return (
    <div>
      <TodoNew />

      {data.listTodos.items.length === 0 ? (
        <p>You have no todos. Add one above.</p>
      ) : (
        <ul>
          {data.listTodos.items.map((item) => (
            <Todo key={item.id} item={item} />
          ))}
        </ul>
      )}
    </div>
  );
}
src/components/TodoNew.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createTodo } from '~/graphql/mutations';
import { client } from '~/components/ConfigureAmplifyClientSide';

export default function TodoNew() {
  const router = useRouter();

  const [name, setName] = useState('');
  const [description, setDescription] = useState('');

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    client
      .graphql({
        query: createTodo,
        variables: { input: { name, description } },
      })
      .then((result) => {
        console.log('result', result);

        router.refresh();

        setName('');
        setDescription('');
      })
      .catch((error) => {
        console.log('error', error);
      });
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();

    const { name, value } = e.currentTarget;

    if (name === 'name') {
      setName(value);
    } else if (name === 'description') {
      setDescription(value);
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <label>
          Name:
          <input name="name" onChange={handleChange} value={name} />
        </label>

        <label>
          Description:
          <input
            name="description"
            onChange={handleChange}
            value={description}
          />
        </label>

        <button type="submit">Create Todo</button>
      </form>
    </div>
  );
}
src/components/Todo.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Todo } from '~/API';
import { client } from '~/components/ConfigureAmplifyClientSide';
import { updateTodo, deleteTodo } from '~/graphql/mutations';

export default function Todo({ item }: { item: Todo }) {
  const router = useRouter();
  const [isEditing, setIsEditing] = useState(false);

  const [name, setName] = useState(item.name);
  const [description, setDescription] = useState(item.description);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    client
      .graphql({
        query: updateTodo,
        variables: { input: { id: item.id, name, description } },
      })
      .then((result) => {
        console.log('result', result);

        router.refresh();

        setIsEditing(false);
        setName('');
        setDescription('');
      })
      .catch((error) => {
        console.log('error', error);
      });
  };

  const handleEditClick = () => {
    setIsEditing(true);
  };

  const handleDeleteClick = () => {
    if (confirm('Are you sure?')) {
      client
        .graphql({
          query: deleteTodo,
          variables: { input: { id: item.id } },
        })
        .then((result) => {
          console.log('result', result);

          router.refresh();
        })
        .catch((error) => {
          console.log('error', error);
        });
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();

    const { name, value } = e.currentTarget;

    if (name === 'name') {
      setName(value);
    } else if (name === 'description') {
      setDescription(value);
    }
  };

  return (
    <li>
      {isEditing ? (
        <form onSubmit={handleSubmit}>
          <label>
            Name:
            <input name="name" onChange={handleChange} value={name} />
          </label>
          <label>
            Description:
            <input
              name="description"
              onChange={handleChange}
              value={description ?? ''}
            />
          </label>
          CreatedAt:
          {item.createdAt} UpdatedAt: {item.updatedAt}
          <button type="submit">Update Todo</button>
        </form>
      ) : (
        <div>
          Name: {item.name} Description: {item.description} CreatedAt:
          {item.createdAt} UpdatedAt: {item.updatedAt}
          <button type="button" onClick={handleEditClick}>
            Edit
          </button>
          <button type="button" onClick={handleDeleteClick}>
            Delete
          </button>
        </div>
      )}
    </li>
  );
}
Gen TamuraGen Tamura

ユーザごとにデータを利用できるようにする

ここまでの設定だと allow: public になっていることに加え、 API の認可が API key になっているので、どのユーザが登録しても、すべてのユーザで共有されてしまいます。

これを Amazon Cognito User Pool を利用して、ログインユーザごとで認可を分けるように変更します。

まずは schema に @auth ディレクティブを追加します。
https://docs.amplify.aws/nextjs/build-a-backend/graphqlapi/customize-authorization-rules/

amplify/backend/api/project-name/schema.graphql
type Todo @model @auth(rules: [{ allow: owner }]) {
  id: ID!
  name: String!
  description: String
}

次に amplify codgen で型を再生成します。

続いて amplify update api で API key から Amazon Cognito User Pool に変更します。

? Select from one of the below mentioned services: GraphQL
? Select a setting to edit Authorization modes
? Choose the default authorization type for the API (Use arrow keys)
  API key
❯ Amazon Cognito User Pool
  IAM
  OpenID Connect
  Lambda
? Configure additional auth types? No

最後に amplify push です。

これでユーザごとにCRUDできるアプリケーションになりました。