Next.js App Router × Amplify Gen2 で認証つき ToDo アプリを作ってデプロイするまで

目次
Next.js App Router と AWS Amplify Gen2 を組み合わせると、認証・データベース・サーバーレス関数を TypeScript のまま定義して、フロントと一緒に CI/CD でデプロイできます。この記事では、認証つき ToDo アプリを題材に、create-next-app から Amplify Hosting へのデプロイまでを順に作ります。ゴールは次の構成です。
- 認証: Cognito(
defineAuth) - データ: DynamoDB(
defineData/ AppSync GraphQL) - 書き込み: Server Actions(cookie ベースのサーバークライアント)
- サーバーレス関数: Lambda を API Gateway で REST 公開
- 軽い API: Next.js API Route
- デプロイ: Amplify Hosting の Git 連携 CI/CD
注意: Amplify Gen2 のデータ層(
defineData)は中身が AppSync = GraphQL です。「REST モード」は無いので、REST が必要な処理は Lambda + API Gateway か Next.js API Route を別途用意する、という住み分けになります。
1. Next.js アプリを作る
App Router / TypeScript / Tailwind で作成します。
npx create-next-app@latest . --ts --app --tailwind --eslint --import-alias "@/*" --use-npm2. Amplify Gen2 を追加する
作成した Next.js プロジェクトの中で Amplify を追加します。
npm create amplify@latestamplify/ ディレクトリが生成されます。ここがバックエンド定義の置き場です。
amplify/
auth/resource.ts … Cognito
data/resource.ts … DynamoDB (AppSync)
backend.ts … 定義の集約点
Amplify Gen2 は「定義(resource.ts)」と「集約(backend.ts)」を分けるのが基本パターンです。
3. 認証(Cognito)を定義する
メール + パスワードでのサインアップ/ログインは数行です。
// amplify/auth/resource.ts
import { defineAuth } from '@aws-amplify/backend';
export const auth = defineAuth({
loginWith: {
email: {
verificationEmailSubject: 'ToDoアプリ 確認コード',
verificationEmailBody: (createCode: () => string) =>
`確認コードは ${createCode()} です。`,
},
},
});4. データ(DynamoDB)を定義する
allow.owner() で「作成した本人だけが読み書きできる」認可にします。userPool を既定にしてログイン必須にします。
// amplify/data/resource.ts
import { type ClientSchema, a, defineData } from '@aws-amplify/backend';
const schema = a.schema({
Todo: a
.model({
content: a.string().required(),
isDone: a.boolean().default(false),
priority: a.enum(['low', 'medium', 'high']),
})
.authorization((allow) => [allow.owner()]),
});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: { defaultAuthorizationMode: 'userPool' },
});5. ローカル開発は sandbox + dev の2枚で回す
Amplify Gen2 にはローカルエミュレーターはありません。代わりに、開発者ごとに実クラウドへ使い捨てスタックを立てる sandbox を使います。ターミナル2枚で並行起動するのが基本形です。
# ターミナル1: バックエンド(実 AWS にデプロイ&変更を監視)
npx ampx sandbox
# ターミナル2: フロント
npm run devsandbox が生成する amplify_outputs.json(Cognito ID や API エンドポイントが入る)を、フロントが読み込んで実 AWS につながります。package.json に登録しておくと便利です。
{
"scripts": {
"sandbox": "ampx sandbox",
"sandbox:delete": "ampx sandbox delete"
}
}
sandboxはローカル開発専用です。CI では使いません(後述の Amplify Hosting がpipeline-deployを自動実行します)。
6. フロントで Amplify を設定する(SSR 対応)
App Router でクライアント設定を行います。{ ssr: true } を付けると、認証トークンが cookie に保存され、サーバー側(Server Actions / Server Components)からも認証を読めるようになります。
// components/ConfigureAmplify.tsx
'use client';
import { Amplify } from 'aws-amplify';
import { parseAmplifyConfig } from 'aws-amplify/utils';
import outputs from '@/amplify_outputs.json';
const amplifyConfig = parseAmplifyConfig(outputs);
Amplify.configure(
{
...amplifyConfig,
// custom.API(自作 REST)は自動展開されないので手動でマージ(後述の Lambda 用)
API: { ...amplifyConfig.API, REST: outputs.custom.API },
},
{ ssr: true }
);
const ConfigureAmplify = () => null;
export default ConfigureAmplify;このコンポーネントを app/layout.tsx で読み込みます。
7. Server Actions で SSR 書き込みする
書き込み(投稿・削除)を Server Action にします。ポイントは、サーバー側で cookie から認証を読む Amplify サーバークライアントを使うことです。これで allow.owner() がそのまま効きます。
まずサーバー用のユーティリティを用意します。
// utils/amplifyServerUtils.ts
import { createServerRunner } from '@aws-amplify/adapter-nextjs';
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/data';
import { cookies } from 'next/headers';
import outputs from '@/amplify_outputs.json';
import type { Schema } from '@/amplify/data/resource';
export const { runWithAmplifyServerContext } = createServerRunner({ config: outputs });
export const cookiesClient = generateServerClientUsingCookies<Schema>({
config: outputs,
cookies,
authMode: 'userPool',
});Server Action 本体です。入力は信頼しないので、認証チェックと zod 検証を先頭に置きます。
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
import { getCurrentUser } from 'aws-amplify/auth/server';
import { cookiesClient, runWithAmplifyServerContext } from '@/utils/amplifyServerUtils';
import { todoContentSchema } from '@/lib/schemas';
const requireUser = async () =>
runWithAmplifyServerContext({
nextServerContext: { cookies },
operation: (ctx) => getCurrentUser(ctx),
});
export type CreateTodoState = { error?: string; ok?: boolean };
export const createTodo = async (
_prev: CreateTodoState,
formData: FormData
): Promise<CreateTodoState> => {
try {
await requireUser();
} catch {
return { error: 'ログインが必要です' };
}
const parsed = todoContentSchema.safeParse(formData.get('content'));
if (!parsed.success) return { error: parsed.error.issues[0].message };
const { errors } = await cookiesClient.models.Todo.create({
content: parsed.data,
isDone: false,
});
if (errors) return { error: errors.map((e) => e.message).join(', ') };
return { ok: true };
};検証スキーマはクライアント/サーバーで共有できるよう切り出します。
// lib/schemas.ts
import { z } from 'zod';
export const todoContentSchema = z
.string()
.trim()
.min(1, '内容を入力してください')
.max(100, '100文字以内で入力してください');フォーム側は useActionState で pending / エラーを一括管理します。React 19 の form action は成功後に非制御フォームを自動リセットしてくれます。
'use client';
import { useActionState } from 'react';
import { createTodo } from '@/app/actions';
const AddTodoForm = () => {
const [state, formAction, isPending] = useActionState(createTodo, {});
return (
<form action={formAction}>
<input name="content" disabled={isPending} placeholder="やることを入力…" />
<button type="submit" disabled={isPending}>
{isPending ? '追加中…' : '追加'}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
};Server Action は「認証必須」ではない点に注意
Server Action の実体は「そのページへの POST」です。Next.js のドキュメントも「すべてのアクションを信頼できない入口として扱え」と明言しています。フレームワークがやってくれるのは CSRF チェックやアクション ID の暗号化までで、認証・認可ではありません。だから requireUser() をアクション先頭に置き、DB に届く前に弾く(fail fast)のが定石です。
8. Lambda を API Gateway で REST 公開する
「フロント → REST → Lambda」を体験するため、sayHello 関数を作って API Gateway で公開します。
// amplify/functions/say-hello/resource.ts
import { defineFunction } from '@aws-amplify/backend';
export const sayHello = defineFunction({
name: 'say-hello',
entry: './handler.ts',
});// amplify/functions/say-hello/handler.ts
import type { APIGatewayProxyHandler } from 'aws-lambda';
export const handler: APIGatewayProxyHandler = async (event) => {
const name = event.queryStringParameters?.name ?? 'World';
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `Hello, ${name}! from Lambda` }),
};
};backend.ts で API Gateway (REST) を組み立て、Lambda を統合し、Cognito で認可します。
// amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { Stack } from 'aws-cdk-lib';
import {
AuthorizationType,
CognitoUserPoolsAuthorizer,
Cors,
LambdaIntegration,
RestApi,
} from 'aws-cdk-lib/aws-apigateway';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { sayHello } from './functions/say-hello/resource';
const backend = defineBackend({ auth, data, sayHello });
const apiStack = backend.createStack('api-stack');
const restApi = new RestApi(apiStack, 'RestApi', {
restApiName: 'myRestApi',
deploy: true,
deployOptions: { stageName: 'dev' },
defaultCorsPreflightOptions: {
allowOrigins: Cors.ALL_ORIGINS,
allowMethods: Cors.ALL_METHODS,
allowHeaders: Cors.DEFAULT_HEADERS,
},
});
const cognitoAuth = new CognitoUserPoolsAuthorizer(apiStack, 'CognitoAuth', {
cognitoUserPools: [backend.auth.resources.userPool],
});
restApi.root.addResource('hello').addMethod(
'GET',
new LambdaIntegration(backend.sayHello.resources.lambda),
{ authorizationType: AuthorizationType.COGNITO, authorizer: cognitoAuth }
);
// amplify_outputs.json にエンドポイントを書き出す
backend.addOutput({
custom: {
API: {
[restApi.restApiName]: {
endpoint: restApi.url,
region: Stack.of(restApi).region,
apiName: restApi.restApiName,
},
},
},
});フロントからは aws-amplify/api の get() で叩きます。Cognito authorizer 用に ID トークンを Authorization ヘッダで渡します。
import { get } from 'aws-amplify/api';
import { fetchAuthSession } from 'aws-amplify/auth';
const { tokens } = await fetchAuthSession();
const op = get({
apiName: 'myRestApi',
path: 'hello',
options: {
queryParams: { name: 'World' },
headers: { Authorization: tokens?.idToken?.toString() ?? '' },
},
});
const { body } = await op.response;
const json = await body.json();ハマりどころ: API name is invalid
get({ apiName: 'myRestApi' }) で API name is invalid が出たら、原因は Amplify.configure(outputs) が custom.API(自作 REST のエンドポイント)を自動展開しないことです。手順6のように、parseAmplifyConfig の結果へ API.REST: outputs.custom.API を手動マージすれば解決します。
9. Next.js API Route も置いてみる
Lambda を増やさず、Next.js のサーバー上で完結する軽い API が欲しいときは Route Handler です。app/api/hello/route.ts がそのまま /api/hello になります。
// app/api/hello/route.ts
import { NextResponse } from 'next/server';
export const GET = async (request: Request) => {
const name = new URL(request.url).searchParams.get('name') ?? 'World';
return NextResponse.json({ message: `Hello, ${name}! from Next.js API Route` });
};Lambda(API Gateway) と Next.js API Route の使い分けはこうです。
| API Gateway + Lambda | Next.js API Route | |
|---|---|---|
| 実行場所 | AWS | Next.js サーバー |
| 認証 | Cognito authorizer(自動) | 自前で JWT 検証 |
| ローカル実行 | sandbox 必須(実クラウド) | npm run dev だけで動く |
| 向き | AWS 連携・外部公開 API | BFF・軽い整形・自前完結 |
10. Amplify Hosting の Git 連携でデプロイする
本番デプロイは Amplify Hosting の CI/CD が最短です。フロントとバックエンドを1つのパイプラインでまとめてデプロイできます。CI が読むビルド設定 amplify.yml をリポジトリ直下に置きます。
# amplify.yml
version: 1
backend:
phases:
build:
commands:
- npm install --cache .npm --prefer-offline
# このブランチのスタックにバックエンドをデプロイ
- npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
phases:
preBuild:
commands:
- npm install --cache .npm --prefer-offline
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- .npm/**/*インストールに
npm ciではなくnpm installを使っています。@aws-amplify/*はバンドル依存を含むため、環境によってはnpm ciの厳密チェックが lock ファイルとの不一致(EUSAGE)で失敗することがあります。npm installはそのズレを許容して整合を取り直すので、CI を安定させるにはこちらが無難です。
あとはコンソールで接続します。
- GitHub に push する
- AWS コンソール → Amplify → Create new app → GitHub を選択 → リポジトリとブランチ(例:
main)を選ぶ - Amplify がフレームワークを自動検出(Next.js + Amplify Gen 2 の両方が出れば OK。バックエンドも一緒にデプロイされる)
- バックエンドの
pipeline-deploy用に IAM サービスロールを作成(画面の指示どおり「Create and use a new service role」を選ぶ) - Save and deploy
$AWS_BRANCH / $AWS_APP_ID は Amplify Hosting が自動注入するので、自分で設定は不要です。以降は main に push するたびに自動で再ビルド&再デプロイが走ります。
ローカルの
sandboxと本番バックエンドは別スタックです。本番用の Cognito/DynamoDB がブランチごとに新規作成されます。sandbox は不要になったらnpx ampx sandbox deleteで消せます。
デプロイ方式の使い分け
| 場面 | フロント | バックエンド |
|---|---|---|
| ローカル開発 | npm run dev |
npx ampx sandbox |
| CI / 本番(Amplify Hosting) | npm run build(自動) |
ampx pipeline-deploy(自動) |
sandbox はローカル専用、pipeline-deploy は CI 専用(監視せず一度だけデプロイして終了)と役割が分かれています。
料金の考え方
この構成は全部サーバーレス(従量課金)なので、常時稼働の固定費はほぼ発生しません。アクセスがゼロなら基本 ≒ 無料枠内です。
- Cognito / DynamoDB / AppSync / Lambda / API Gateway: すべて使った分だけ。アイドル時はほぼ $0
- 「立っているだけで課金」になる RDS や VPC の NAT Gateway を使っていないのがポイント(DynamoDB 構成の利点)
- 心配なら AWS Budgets で「月 $1 超えたら通知」を設定しておくと安心
まとめ
- Amplify Gen2 は 認証・データ・関数を TypeScript で定義でき、Next.js App Router と一緒に CI/CD デプロイできる
- データ層は GraphQL(AppSync)。REST が欲しい処理は Lambda + API Gateway か Next.js API Route で補う
- 書き込みは Server Action + cookie ベースのサーバークライアントで、
allow.owner()をそのまま活かせる - Server Action は認証必須ではないので、先頭で認証・zod 検証して弾く
- 本番は Amplify Hosting の Git 連携が最短。
amplify.ymlを置いて push すれば自動デプロイ
参考ソース
関連記事

AWS API Gateway の REST API(v1) と HTTP API(v2) を、機能・料金・認証・Terraform の書き味の観点で比較します。公式のフィーチャーマトリクスと料金表を出典つきで整理し、「迷ったら HTTP API、これが要るなら REST」という選定基準に落とし込みます。

git ルートに husky を置き、frontend の ESLint(no-console・any 禁止・未使用/import 整列)と Prettier(Tailwind クラス整列)を lint-staged 経由で pre-commit 自動実行する構成を、実際の設定ファイルとハマりどころつきで解説します。

app/ はルーティング専用に薄く保ち、ドメインロジックは features/ に寄せる。Server Actions の命名規約と、認証を lib/auth に集約し認可をデータ操作とセットで書く3層の考え方を整理します。