naoki.dev

記事検索

タイトル・本文・タグから検索します

ホームに戻る
バックエンド

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-npm

2. Amplify Gen2 を追加する

作成した Next.js プロジェクトの中で Amplify を追加します。

npm create amplify@latest

amplify/ ディレクトリが生成されます。ここがバックエンド定義の置き場です。

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 dev

sandbox が生成する 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/apiget() で叩きます。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 を安定させるにはこちらが無難です。

あとはコンソールで接続します。

  1. GitHub に push する
  2. AWS コンソール → AmplifyCreate new app → GitHub を選択 → リポジトリとブランチ(例: main)を選ぶ
  3. Amplify がフレームワークを自動検出(Next.js + Amplify Gen 2 の両方が出れば OK。バックエンドも一緒にデプロイされる)
  4. バックエンドの pipeline-deploy 用に IAM サービスロールを作成(画面の指示どおり「Create and use a new service role」を選ぶ)
  5. 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 すれば自動デプロイ

参考ソース

関連記事