作成するアプリのイメージ
Amplify CLIを使用し、CRUD機能を備えた簡単なtodoアプリを制作します。

技術スタック
- フロントエンド:Next.js 13.4.19
- バックエンド:AppSync (今回はLambda Functionの実装はしていません)
その他パッケージのバージョン
- npm version 9.8.1
- amplify cli 12.3.0
- aws-amplify/cli@10.6.2 ※12.3.0の場合mock apiでスキーマが生成されなかったため、ダウングレードしました。
- aws-amplify/cli@12.3.0 ※mock apiでスキーマが生成されなかったため、mock apiでの動作確認時のみ10.6.2にダウングレードしました。
- Recoil 0.7.7
- React-hook-form 7.45.4
- Material-UI 5.14.6
Amplify CLIのセットアップ
公式サイトを参考に実施してください。
https://docs.amplify.aws/cli/start/install/
Amplify CLIのインストール
インストール/インストールしたバージョンを確認$ npm install -g @aws-amplify/cli
$ amplify -v
IAMユーザーの作成
ポリシー:AdministratorAccess-Amplifyをアタッチ$ amplify configure
Next.jsの初期設定
https://nextjs.org/docs/getting-started/installation
公式のインストールガイドに沿って初期プロジェクトを作成する
$ npx create-next-app@latest
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@13.4.19
Ok to proceed? (y) y
✔ What is your project named? … amplify-cli-todo-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/aguru/Desktop/amplify-cli-todo-app.
Amplifyの初期設定
Next.jsプロジェクトのルートディレクトリで実行する
$ amplify init
初期設定の際に先に設定したProfile名を指定する。
Hostingの設定
$ amplify add hosting
✔ Select the plugin module to execute · Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type Continuous deployment (Git-based deployments)
? Continuous deployment is configured in the Amplify Console. Please hit enter once you connect your repository
Amplify hosting urls:
┌──────────────┬────────────────────────────────────────────┐
│ FrontEnd Env │ Domain │
├──────────────┼────────────────────────────────────────────┤
│ main │ https://main.d1i7g92hfrd9va.amplifyapp.com │
└──────────────┴────────────────────────────────────────────┘
アプリケーション構築の前に先に設定をしておく。
また、この時点でデプロイができることを確認しておくと良いです。
https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/deploy-nextjs-app.html
認証の設定
$ amplify add auth
Using service: Cognito, provided by: awscloudformation
The current configured provider is Amazon Cognito.
Do you want to use the default authentication and security configuration? Default configuration
Warning: you will not be able to edit these selections.
How do you want users to be able to sign in? Username
Do you want to configure advanced settings? No, I am done.
✅ Successfully added auth resource amplifyclitodoappc6058fff locally
✅ Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud
$ amplify push
✔ Successfully pulled backend environment dev from the cloud.
Current Environment: dev
┌──────────┬───────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name │ Operation │ Provider plugin │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Auth │ amplifyclitodoappc6058fff │ Create │ awscloudformation │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Hosting │ amplifyhosting │ Update │ │
└──────────┴───────────────────────────┴───────────┴───────────────────┘
✔ Are you sure you want to continue? (Y/n) · yes
Deployment completed.
Deploying root stack amplifyclitodoapp [ =============--------------------------- ] 1/3
amplify-amplifyclitodoapp-dev… AWS::CloudFormation::Stack UPDATE_IN_PROGRESS Sat Aug ~~~~~~~~~~~~~~~
authamplifyclitodoappc6058fff AWS::CloudFormation::Stack CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
Deployed auth amplifyclitodoappc6058fff [ ======================================== ] 6/6
UserPool AWS::Cognito::UserPool CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
UserPoolClientRole AWS::IAM::Role CREATE_IN_PROGRESS Sat Aug ~~~~~~~~~~~~~~~
UserPoolClientWeb AWS::Cognito::UserPoolClient CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
UserPoolClient AWS::Cognito::UserPoolClient CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
IdentityPool AWS::Cognito::IdentityPool CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
IdentityPoolRoleMap AWS::Cognito::IdentityPoolRol… CREATE_COMPLETE Sat Aug ~~~~~~~~~~~~~~~
Deployment state saved successfully.
APIの設定
GraphQL APIを追加
$ amplify add api
https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/
モックサーバーの設定
javaがローカル環境にインストールされていない場合は、javaをインストールする
javaをインストールしてpathを通す/openjdk@20のバージョン指定はインストールしたjavaを指定すること$ brew install java
$ sudo ln -s /usr/local/opt/openjdk@20/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jd
$ java -version
先にAmazon Cognitoでユーザーとグループを作成する。
グループを作成したユーザーと紐付けをする。

スキーマ定義
このスキーマ定義では認証ルールとして、[”Users”]グループに属したアカウントのみ、CRUDができるように設定しています。
amplify/backend/api/[プロジェクト名]/schema.graphqltype Todo
@model
@auth(
rules: [
{
provider: userPools
allow: groups
groups: ["Users"]
operations: [create, update, read, delete]
}
]
) {
id: ID!
name: String!
completed: Boolean!
}
$ amplify mock api
ローカル環境で検証できることを確認

必要ライブラリーのインストール
material UIのインストール$ npm install @mui/material @emotion/react @emotion/styled aws-amplify @aws-amplify/ui-react react-hook-form recoil
https://mui.com/material-ui/getting-started/installation/
Build時にエラーが出たため追加したライブラリー
$ npm i -d encoding
$ npm i aws-crt
aws-crt関連でcompileに失敗するのでwebpackの設定を修正する。
/next.config.js/** @type {import('next').NextConfig} */
const webpack = require("webpack");
const nextConfig = {
webpack: (config, { isServer, nextRuntime }) => {
// Avoid AWS SDK Node.js require issue
if (isServer && nextRuntime === "nodejs")
config.plugins.push(
new webpack.IgnorePlugin({ resourceRegExp: /^aws-crt$/ })
);
return config;
},
};
module.exports = nextConfig;
実装方法(主要な実装を記載)
URL設計(作成したページ)は下記になります。
- トップページ:todo一覧
- /todos/new:todo新規登録ページ
- /todos/:id:編集ページ (/todos/:id/edit でも良い。その場合はpage.tsxではなく、edit.tsxファイル上で編集ページを生成する)
共通ファイル
src/app/layout.tsximport "@/app/globals.css";
import { theme } from "@/app/theme";
import type { Metadata } from "next";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import RecoilProvider from "@/store/Provider";
import { Container } from "@mui/material";
import "@aws-amplify/ui-react/styles.css";
import { Header } from "@/components/Header";
import { AuthListener } from "@/components/AuthListener";
export const metadata: Metadata = {
title: "Todo App",
description: "Created by Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
{/* ①RecoilのProviderで全体を囲む */}
<RecoilProvider>
{/* ②ログイン、ログアウト、アクセス時に */}
{/* 認証情報をRecoilに保存するためのリスナー */}
<AuthListener />
<ThemeProvider theme={theme}>
<CssBaseline />
<body>
<Header />
<Container>{children}</Container>
</body>
</ThemeProvider>
</RecoilProvider>
</html>
);
}
【①RecoilのProviderで全体を囲む】
RecoilRootコンポーネントは“user client”(CSR)をする必要があるため、別ファイルとして切り出しています。
src/store/Provider.tsx"use client";
import { RecoilRoot } from "recoil";
const RecoilProvider = ({ children }: { children: React.ReactNode }) => {
return <RecoilRoot>{children}</RecoilRoot>;
};
export default RecoilProvider;
src/store/authState.tsimport { atom } from "recoil";
export const authState = atom<boolean>({
key: "authState",
default: false,
});
src/store/todoState.tsimport { atom } from "recoil";
type Todo = {
id: string;
name: string;
completed: boolean;
createdAt: string;
updatedAt: string;
} | null;
const todosState = atom<Todo[]>({
key: "todos",
default: [],
});
export { todosState };
【②ログイン、ログアウト、アクセス時に認証情報をRecoilに保存するためのリスナー】
- ログイン時にはヘッダーの「ログアウト」ボタンを表示
- ログアウト時にはヘッダーの「ログアウト」ボタンを非表示
など、認証に合わせた表示切り替えや機能実装に認証情報が必要なため実装しました。
hooks/userAuthHook.ts"use client";
import { useSetRecoilState } from "recoil";
import { authState } from "@/store/authState";
import { Auth, Hub } from "aws-amplify";
import { useEffect } from "react";
const useAuthHook = () => {
const setAuth = useSetRecoilState(authState);
useEffect(() => {
const checkAuthState = async () => {
try {
// ページがロードまたはリロードされたとき、アプリは現在の認証状態を確認する。
await Auth.currentAuthenticatedUser();
setAuth(true);
} catch (error) {
setAuth(false);
}
};
checkAuthState();
const unsubscribe = Hub.listen("auth", ({ payload: { event } }) => {
// アプリのライフサイクル中(ログイン、ログアウト等)に、ユーザーの認証状態の変更を検知する。
switch (event) {
case "signIn":
setAuth(true);
break;
case "signOut":
setAuth(false);
break;
}
});
// useEffectのクリーンアップ関数
return () => unsubscribe();
}, [setAuth]);
};
export { useAuthHook };
src/components/AuthListener.tsx"use client";
import { useAuthHook } from "@/hooks/useAuthHook";
const AuthListener = () => {
useAuthHook();
return null;
};
export { AuthListener };
todo一覧
src/app/page.tsx"use client";
import React from "react";
import { useEffect } from "react";
import Link from "next/link";
import { Amplify, API, graphqlOperation } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button, Grid } from "@mui/material";
import { ListTodosQuery } from "@/API";
import { useRecoilState } from "recoil";
import { todosState } from "@/store/todoState";
import { listTodos } from "@/graphql/queries";
import { Todo } from "@/components/todo";
Amplify.configure(awsconfig);
const TodosIndex = () => {
const [todos, setTodos] = useRecoilState(todosState);
useEffect(() => {
const asyncFunc = async () => {
const result = (await API.graphql(
graphqlOperation(listTodos)
)) as GraphQLResult<ListTodosQuery>;
setTodos(result?.data?.listTodos?.items || []);
};
asyncFunc();
}, [setTodos]);
return (
<>
<Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
<Grid item md={6}>
<h1>Todos</h1>
</Grid>
<Grid item md={6}>
<Link href="/todos/new">
<Button variant="contained" color="primary">
New
</Button>
</Link>
</Grid>
</Grid>
<Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
{todos.map((todo) =>
todo ? <Todo key={todo.id} todo={todo} /> : null
)}
</Grid>
</>
);
};
export default withAuthenticator(TodosIndex);
src/components/Todo.tsximport Link from "next/link";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { DeleteTodoMutation, UpdateTodoMutation } from "@/API";
import { deleteTodo, updateTodo } from "@/graphql/mutations";
import {
Card,
CardActions,
CardContent,
Typography,
Button,
Grid,
FormControlLabel,
Checkbox,
} from "@mui/material";
import { useRecoilState } from "recoil";
import { todosState } from "@/store/todoState";
import { FC } from "react";
type TodoType = {
id: string;
name: string;
completed: boolean;
createdAt: string;
updatedAt: string;
} | null;
type TodoProps = {
todo: TodoType;
};
const Todo: FC<TodoProps> = ({ todo }) => {
const [todos, setTodos] = useRecoilState(todosState);
const onArchive = async () => {
if (!todo) return;
(await API.graphql(
graphqlOperation(deleteTodo, {
input: {
id: todo.id,
},
})
)) as GraphQLResult<DeleteTodoMutation>;
setTodos(todos.filter((item) => item && item.id !== todo.id));
};
const handleChangeDone = async (e: { target: { checked: boolean } }) => {
if (!todo) return;
const isChecked = e.target.checked;
try {
const result = (await API.graphql(
graphqlOperation(updateTodo, {
input: {
id: todo.id,
completed: isChecked,
},
})
)) as GraphQLResult<UpdateTodoMutation>;
const updatedTodo = result?.data?.updateTodo;
setTodos(
todos.map((item) =>
item && item.id === todo.id && updatedTodo ? updatedTodo : item
)
);
} catch (error) {
console.error("Error updating todo:", error);
}
};
if (!todo) {
return null;
}
return (
<Grid item md={6}>
<Card>
<CardContent>
<Typography variant="h5" component="h2">
<Link href={`/todos/${todo.id}`}>{todo.name}</Link>
</Typography>
<Typography color="textSecondary">
created at {new Date(todo.createdAt).toLocaleString()}
</Typography>
</CardContent>
<CardActions>
<FormControlLabel
control={
<Checkbox
color="primary"
onChange={handleChangeDone}
checked={todo.completed}
/>
}
label="Done"
/>
<Button variant="contained" color="secondary" onClick={onArchive}>
Archive
</Button>
</CardActions>
</Card>
</Grid>
);
};
export { Todo };
todo新規登録ページ
src/app/todos/new/page.tsx"use client";
import { useRouter } from "next/navigation";
import { Typography } from "@mui/material";
import { Grid } from "@mui/material";
import { Amplify } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { CreateTodoMutation } from "@/API";
import { createTodo } from "@/graphql/mutations";
import Form from "@/components/Form";
Amplify.configure(awsconfig);
const TodoNewPage = () => {
const router = useRouter();
const onSubmit = async (name: string) => {
try {
const result = (await API.graphql(
graphqlOperation(createTodo, {
input: {
name,
completed: false,
},
})
)) as GraphQLResult<CreateTodoMutation>;
router.push("/");
} catch (error) {
console.error("Error creating todo:", error);
}
};
return (
<>
<Typography variant="h5" sx={{ marginTop: 2 }}>
Todoの新規登録
</Typography>
<Grid sx={{ marginTop: 1 }}>
<Form onSubmit={onSubmit} />
</Grid>
</>
);
};
export default withAuthenticator(TodoNewPage);
【新規登録・編集で共有のFormコンポーネント】
React-Hook-Formを使用しています。入力値の状態管理やバリデーション設定の管理がし易い印象です。
src/components/Form.tsximport { Controller, SubmitHandler, useForm } from "react-hook-form";
import { TextField, Button, Grid } from "@mui/material";
import { FC } from "react";
type Inputs = {
name: string;
};
type FormProps = {
onSubmit: (name: string) => void;
name?: string;
};
const Form: FC<FormProps> = (props) => {
const { control, handleSubmit, setValue } = useForm<Inputs>({
defaultValues: { name: props.name || "" },
});
const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
props.onSubmit(data.name);
};
const validationRules = {
name: {
required: "タイトルを入力してください。",
maxLength: { value: 30, message: "30文字以内で入力をしてください。" },
},
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container direction="column" spacing={2}>
<Grid item md={6}>
<Controller
name="name"
control={control}
rules={validationRules.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
label="TODOのタイトル"
error={fieldState.invalid}
helperText={fieldState.error?.message}
/>
)}
/>
</Grid>
<Grid item md={6}>
<Button type="submit" variant="contained" color="primary">
Save
</Button>
</Grid>
</Grid>
</form>
);
};
export default Form;
todo編集ページ
src/app/todos/[id]/page.tsx"use client";
import { useParams, useRouter } from "next/navigation";
import { Typography, Grid } from "@mui/material";
import { Amplify } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { GetTodoQuery, UpdateTodoMutation } from "@/API";
import { updateTodo } from "@/graphql/mutations";
import Form from "@/components/Form";
import { useEffect, useState } from "react";
import { getTodo } from "@/graphql/queries";
Amplify.configure(awsconfig);
const TodoEditPage = () => {
const [todoName, setTodoName] = useState<string | null>(null);
const id = useParams().id;
const router = useRouter();
useEffect(() => {
if (id) {
fetchTodoDetails(id as string);
}
}, [id]);
const fetchTodoDetails = async (todoId: string) => {
try {
const result = (await API.graphql(
graphqlOperation(getTodo, { id: todoId })
)) as GraphQLResult<GetTodoQuery>;
if (result.data && result.data.getTodo) {
setTodoName(result.data.getTodo.name);
}
} catch (error) {
console.error("Error fetching todo details:", error);
}
};
const onSubmit = async (updatedName: string) => {
try {
const result = (await API.graphql(
graphqlOperation(updateTodo, {
input: {
id,
name: updatedName,
},
})
)) as GraphQLResult<UpdateTodoMutation>;
router.push("/");
} catch (error) {
console.error("Error updating todo:", error);
}
};
if (todoName === null) return null;
return (
<>
<Typography variant="h5" sx={{ marginTop: 2 }}>
Todoの編集
</Typography>
<Grid sx={{ marginTop: 1 }}>
<Form onSubmit={onSubmit} name={todoName} />
</Grid>
</>
);
};
export default withAuthenticator(TodoEditPage);
スキーマを定義すれば、基本的なCRUDに必要なQuery, Mutationを自動生成してくれる。もちろん、複雑なことをするにはLamdba Functionの活用や、DynamoDBのインデックス設計等が必要になるが、比較的すぐにアプリを作成できたことに衝撃!!!