🍳

SSTを使ってAWSにNextjsのフルスタックアプリ作る

に公開

はじめに

AWSのIaCの選択はたくさんありますが、今回はSST(Serverless Stack)を使ってフルスタックアプリを作ります。

感想

AWS CDKとの比較ですが

  • シンプル
  • デプロイが早い
  • 開発しやすい
  • 気軽にNextjsが使える

構成

このような構成とします。

github

https://github.com/webshotenorg/aws_sst_nextjs_lambda_monorepo

NextjsのAWSデプロイについて

今までNextjsのAWSデプロイ先として選定されていたのはECSでした。というのもNextjsはSSRやISRなどサーバー側で動作する機能が多くあり、それらを機能させるためにはS3+Cloudfrontでは不可能でした。かといってAmplifyでは問題がいくつもありました。

https://zenn.dev/dani_rk/articles/limit-of-nextjs-on-amplify

↑勝手にリンク貼らせていただいています💦
結果的にAWSでサーバーレスというとSPAを選択する結論が多かった気がしています。

open-next

2年前(2023年ごろ)現たのがOpenNextです。OpenNextとはNext.jsアプリをVercel以外のプラットフォームでホスティングできるようにするオープンソースの取り組みです。現在CloudflareとSSTの開発メンバーがバックアップしてるようです。

結論、SSTではデフォルトでこのOpenNextを使うことで簡単にデプロイできるようになっています。静的ファイルについてはS3、動的なものはLambdaでデプロイし、CloudFrontから参照するような形で自動的にデプロイされます。

はじめる

こちらからSSTのモノレポの雛形を落としてきます。
https://sst.dev/docs/set-up-a-monorepo/

SSTの公式にあるように、自分のプロジェクト名に変えます(MY_APPのところ)。

npx replace-in-file /monorepo-template/g MY_APP **/*.* --verbose

パッケージをインストールします

npm install

ローカルにAWS認証情報がある状態であれば、この状態でSSTのデプロイが可能です。

npx sst dev

モノレポサンプルはS3とLambdaだけの構成なのでこれから修正していきます。

インフラ

sst.config.tsを構成通りになるよう修正します。

  • 全体サービス名:nextjsapp
  • Nextjs関連名(cloudfront,s3,lambda):MyWeb
  • API関連名(APIGateway,lambda):MyApi
  • DynamoDB関連名(DynamoDB):MyTable
//sst.config.ts

/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "nextjsapp",
      removal: input?.stage === "production" ? "retain" : "remove",
      protect: ["production"].includes(input?.stage),
      home: "aws",
    };
  },
  async run() {
    const table = new sst.aws.Dynamo("MyTable", {
      fields: {
        userId: "string",
        noteId: "string",
      },
      primaryIndex: { hashKey: "userId", rangeKey: "noteId" },
    });

    const api = new sst.aws.ApiGatewayV2("MyApi", {
      cors: {
        allowMethods: ["GET"],
        allowOrigins: ["*"],
      },
      link: [table],
    });

    api.route("GET /", "packages/functions/src/api.handler");

    new sst.aws.Nextjs("MyWeb", {
      path: "packages/web",
      link: [api],
    });

    return {
      MyApi: api,
      MyTable: table,
    };
  },
});

SSTではlinkオプションで権限を付与します。CDKではDynamoDBへのRead権限をLambdaに付与することでデータを読み取ることができるようになりますが、SSTはこれだけです。

functionsとcoreのロジックも載せておきます。

// packages/functions/src/api.ts

import { Handler } from "aws-lambda";
import { Example } from "@nextjsapp/core/example";
import { User } from "../../core/src/user";
import { docClient } from "@nextjsapp/core/dynamodb";

export const handler: Handler = async (_event) => {
  console.log(_event);
  // DynamoDBのテーブル内容(userId=1)をそのまま返すだけ
  return {
    statusCode: 200,
    body: `${Example.hello()} : ${
      JSON.stringify(
        await User.getUser(docClient, {
          userId: "1",
        }),
      )
    }.`,
  };
};
// packages/core/src/user/index.ts

import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { Resource } from "sst";

export interface UserType {
    userId: string;
    noteId: string;
}

export namespace User {
    export async function getUser(
        docClient: DynamoDBDocumentClient,
        params: { userId: string },
    ): Promise<UserType[]> {
        const res = await docClient.send(
            new QueryCommand({
                TableName: Resource.MyTable.name,
                KeyConditionExpression: "userId = :userId",
                ExpressionAttributeValues: {
                    ":userId": params.userId,
                },
            }),
        );
        return res.Items as UserType[];
    }
}
// packages/core/src/dynamoDb/index.ts

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient();
export const docClient = DynamoDBDocumentClient.from(client);

Nextjsはこんな感じ(Nextjsの初期化は省略します)

// web/src/app/page.tsx

import { Resource } from 'sst';

export default async function Home() {
  const data = await fetch(Resource.MyApi.url);
  console.log(Resource);
  return <div>{await data.text()}</div>;
}

ESLint

eslint-config-serverless-stackを導入します

npm i -D eslint-config-serverless-stack
// package.json
{
・・・
  "devDependencies": {
  ・・・
    "eslint-config-serverless-stack": "^0.69.7",
   ・・・
  },

・・・
  "eslintConfig": {
    "extends": [
      "serverless-stack"
    ]
  }
・・・
}

それ以外はモノレポ用のeslint,prettierのルート設定をします。

Debug

.vscode/launch.jsonを以下のように記述します
これで実Lambdaへのリモートデバッグができるようになります!

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug SST Dev",
            "type": "node",
            "request": "launch",
            "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
            "runtimeArgs": ["dev"],
            "console": "integratedTerminal",
            "skipFiles": ["<node_internals>/**"]
          }
    ]
  }

最後に感想

sstにビルドのみのオプションが欲しいですね。

リソース情報を参照できるsst-env.d.tsがデプロイ後じゃないと
生成されません。つまり単独でNextjsのビルドをしてもTypeエラーとなってしまいます。
このあたりなんとかしてくれると非常にありがたいですね〜。

補足*1 Lambda個別にPermissionを与えるには

//sst.config.ts

・・・
    const func = new sst.aws.Function("MyFunction", {
      url: false,
      handler: "packages/functions/src/api.handler",
      permissions: [
        {
          actions: ["dynamodb:Query"],
          resources: [table.arn],
        },
      ],
      link: [table],
    });

    const api = new sst.aws.ApiGatewayV2("MyApi", {
      cors: {
        allowMethods: ["GET"],
        allowOrigins: ["*"],
      },
    });

    api.route("GET /", func.arn);
・・・

Discussion