Next.js App Router with AWS Amplify JavaScript Library v6
Amplify で Next.js の App Router を利用できる。以下を試すためのメモ。
概要
- 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 を接続する
Next.js のプロジェクト作成
npx create-next-app@">=13.5.0 <15.0.0" next-amplified
cd next-amplified
npm run dev
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
Amplify Backend の準備
まずは Amplify Backend の準備をします
amplify init
各種設定が求められるので、必要に応じて選択します。(お試しであれば、基本デフォルトでOKです。)
AWS Profile だけ default でない場合は、適切な AWS Profile を選んでください。
上記設定が終わると、以下が追加されます
- プロジェクトルートに
amplify
ディレクトリが追加される- amplify に関連するファイル郡
-
src
ディレクトリにamplifyconfiguration.json
とaws-exports.js
ファイルが追加される- frontend アプリケーションから amplify backend にアクセスするためのファイル
- これら2つのファイルは自動更新されるため、直接編集しない。また .gitignore の対象なので、取り扱いに気をつける
次に、アプリケーションで利用するライブラリを追加します。
npm install aws-amplify @aws-amplify/adapter-nextjs
これで Amplify Backendの準備が整いました。
Next.js の server-side runtimes で Amplify Backend を利用する
サーバサイドで Amplify Backend を利用するために以下を追加する。(createServerRunner
は一度だけ呼び出すので、RSCで呼び出すなら force-dynamic
で毎回キャッシュなしのフルレンダリング、または middleware or API Routeでの利用になる)
import { createServerRunner } from '@aws-amplify/adapter-nextjs';
import config from '@/amplifyconfiguration.json';
export const { runWithAmplifyServerContext } = createServerRunner({
config
});
React Server Components で利用するには、例えば、以下のように、 ログイン済みのユーザデータを表示します。
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
のみで利用できるようです。)
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 を利用するため、以下を追加する。
'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
は一度だけ呼び出したいので、ルートレイアウトに設置します。
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 を参照することができます。
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 を設定を読み込みます。
'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 を実装し、
'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>
);
}
ルートレイアウトにマウントします。
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;
続いて、 hook で参照するコンポーネントを作成し、 page で呼び出します。
'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 />
);
}
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>
);
}
Amplify Backend に 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
で更新します。)
# 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 で利用できるように設定します。
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,
});
Next.js を Amplify Hosting に deploy
これは docs そのままなので割愛。
Project を GitHub に push して、AWS Amplify hosting にホストするように、AWS コンソールから設定する。
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 などはサンプルコードなどからも参照しているので、あまり一貫性はないので、ご了承ください 🙏
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。
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 コンポーネント が表示される。
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 をるけるのかは悩ましいところ。)
以下はコンポーネント群。
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 を取得しています。
'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 で切り出しています。
'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 です。
'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 でデータを取得してレンダリングしています。
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>
);
}
'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>
);
}
'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>
);
}
ユーザごとにデータを利用できるようにする
ここまでの設定だと allow: public
になっていることに加え、 API の認可が API key になっているので、どのユーザが登録しても、すべてのユーザで共有されてしまいます。
これを Amazon Cognito User Pool を利用して、ログインユーザごとで認可を分けるように変更します。
まずは schema に @auth ディレクティブを追加します。
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できるアプリケーションになりました。