Next.jsでSpotifyAPIを使用したWebアプリをvpsに公開するまで

# 概要
この記事では、初心者の方がこの記事を読みながら周辺知識を調べてWebアプリを作成して公開までできるように、実際の環境構築から公開までした内容をまとめました。今回は環境構築しローカルで作動させるまでです。

##### 使用するもの
- Node.js
JavaScriptでサーバーサイドプログラムを実行するためのランタイム環境。Next.jsを動作させる基盤として使用します。
- Next.js
Reactベースのフレームワークで、フロントエンドとバックエンド（APIエンドポイント）を統合的に開発できます。
- NextAuth
認証と認可を簡単に実装できるライブラリ。今回はSpotifyアカウントを利用したOAuth認証を設定します。
- Prisma
モダンなORM（Object-Relational Mapping）ツール。PostgreSQLとのデータベース操作ができます。
- Recoil
Reactで状態管理を行うためのライブラリ。アプリ内のグローバルな状態管理を行います。
- Docker
Dockerのコンテナを使用してPostgreSQLのデータベースを構築します。また、アプリ公開の際にDockerでコンテナ化して公開します。サーバーの環境設定不要で公開することができます。
- PostgreSQL
リレーショナルデータベース。Dockerで構築します。
- Spotify API
Spotifyの音楽データにアクセスするためのAPI。
- Bootstrap
アプリのレスポンシブルデザインにBootstrapのテンプレートを使用します。

##### 公開アプリ

http://tunetrek.iut-b.site:3000/

##### GitHub


https://github.com/IUT-b/music


## 開発環境の構築

### Node.jsのインストール
Node.jsはJavaScriptの実行環境で、バックエンドや開発サーバーを動かすのに必要で、Next.jsでアプリ作成するために必須です。次のリンクから最新バージョンをダウンロードしてインストールします。

https://nodejs.org/en

### nvmのインストール
nvmは複数のNode.jsのバージョンを管理できるツールです。各種パッケージの依存関係のエラーを回避するため特定のバージョンで環境を設定します。

https://github.com/coreybutler/nvm-windows/releases

nvmを使って、Node.jsのバージョン18をインストールします。
```bash
nvm install 18
```

Node.jsのバージョン18を使用するように切り替えます。
```bash
nvm use 18
```

### Next.jsのプロジェクト作成
VSCodeのターミナルを使用して、Next.jsのプロジェクトを作成するフォルダへ移動し次のコマンドでプロジェクトを作成します。
```bash
npx create-next-app@latest .
```
ターミナルでスクリプトが実行できない場合は以下を実行します。PowerShellのスクリプト実行ポリシーを変更してスクリプトを実行可能とします。
```bash
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```

プロジェクトのフォルダ構成は以下のようになります。

```plaintext
project/
├── .next/                        
├── node_modules/           
├── public/                       
├── src/                          
│   ├── app/
│   │   ├── api/                  # バックエンドのAPIを作成
│   │   ├── components/           # 再利用するコンポーネントを作成
│   │   ├── favorites/            # お気に入りのページを作成
│   │   ├── fonts/
│   │   ├── playlists/            # プレイリストのページを作成
│   │   ├── top-tracks/           # トップトラックのページを作成
│   │   ├── global.css            
│   │   ├── layout.tsx            # アプリ全体のレイアウトを設定
│   │   ├── next.auth.d.ts        # NextAuth.jsで使用する型定義
│   │   └── page.tsx              # トップページ
│   ├── lib/                      # ユーティリティ関数を作成
│   └── types/                    # TypeScriptの型定義
├── .env                          # 環境変数ファイルを設定
├── .eslintrc.json                
├── next-env.d.ts                 
├── next.config.js                # Next.js設定
├── package-lock.json             
├── package.json                  
├── postcss.config.mjs            
├── README.md                     
├── tailwind.config.ts            
└── tsconfig.json                 
```

### Dockerのインストール
アプリで使用するデータベースをPostgreSQLとし、Dockerコンテナを使用して起動することにしますので、Docker Desktopをインストールします。

https://www.docker.com/

### PostgreSQLの設定
Dockerを使ってPostgreSQLの環境を構築します。初めにDockerのコンテナを設定・起動するためのファイルを作成します。
```yaml:docker-compose.yml
version: "3.9"
services:
  postgres:
    image: postgres:15
    container_name: postgres_container
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
volumes:
  postgres_data:
```

アプリでPostgreSQLを使用するための環境変数を設定します。
```:.env
DATABASE_URL="postgresql://my_user:my_password@localhost:5432/my_database?schema=public"

POSTGRES_USER=my_user
POSTGRES_PASSWORD=my_password
POSTGRES_DB=my_database
```

`docker-compose.yml`の設定でpostgreSQLコンテナを起動します。
```bash
docker-compose up –d
```

:::note warn
コンテナ起動する前にDocker Desktopを起動してください。
:::

コンテナを停止するときは以下のコマンドを入力します。
```bash
docker-compose down
```

## アプリ起動
この段階で一度アプリを起動してみます。必要なパッケージをインストールします。
```bash
npm install
```
ローカル環境でアプリを起動します。
```bash
npm run dev
```
アプリ起動後に以下で確認します。

http://localhost:3000/

## SpotifyAPIの連携
Spotify APIを使うことで、Spotifyアカウントのデータにアクセスし、音楽データを取得できます。Spotify Developer Dashboardでのアプリ登録、APIキーを取得します。

https://developer.spotify.com/

Spotify Web API を操作するための Node.js ライブラリ spotify-web-api-node をインストールします。このライブラリを使うと、SpotifyのAPIに簡単にアクセスできます。
```bash
npm install spotify-web-api-node
```
```bash
npm install --save-dev @types/spotify-web-api-node
```
```bash
npm install node-fetch
```
```bash
npm install ts-node --save-dev
```
SpotifyAPIを使用するため環境変数を設定します。
```diff:.env
DATABASE_URL="postgresql://my_user:my_password@localhost:5432/my_database?schema=public"

POSTGRES_USER=my_user
POSTGRES_PASSWORD=my_password
POSTGRES_DB=my_database

+ SPOTIFY_CLIENT_ID= # ここにSpotifyのクライアントIDを入力
+ SPOTIFY_CLIENT_SECRET= # ここにシークレットキーを自由に設定
```

## Next.jsのアプリ構築

### NextAuth
NextAuthは、簡単に認証機能を実装できるライブラリです。NextAuthを使用するためアプリの環境変数を設定します。
```diff:.env
DATABASE_URL="postgresql://my_user:my_password@localhost:5432/my_database?schema=public"

POSTGRES_USER=my_user
POSTGRES_PASSWORD=my_password
POSTGRES_DB=my_database

SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=

+ NEXTAUTH_SECRET= # ここにシークレットキーを自由に設定
+ NEXTAUTH_URL=http://localhost:3000
```


NextAuthを使用するためのAPIエンドポイントを設定します。
```ts:src\app\api\auth\[...nextauth]\route.ts
import NextAuth, { NextAuthOptions } from "next-auth";
import { authOptions } from "@/lib/auth";

// NextAuthのインスタンスをエクスポート
const handler = NextAuth(authOptions as NextAuthOptions);

export { handler as GET, handler as POST };
```

Spotify認証の設定をします。
```ts:src\lib\auth.ts
import { Session } from "next-auth";
import { JWT } from "next-auth/jwt";
import SpotifyProvider from "next-auth/providers/spotify";
import { prisma } from "@/lib/prisma";

// Spotify Web APIから取得する情報
const scopes = [
  "user-read-private",
  "user-read-email",
  "user-top-read",
  "playlist-read-private",
  "playlist-read-collaborative",
  "playlist-modify-private",
  "playlist-modify-public",
  "user-read-playback-state",
  "user-read-currently-playing",
  "user-modify-playback-state",
  "user-library-read",
  "user-library-modify",
].join(" ");

// NextAuthの設定
export const authOptions = {
  providers: [
    SpotifyProvider({
      clientId: process.env.SPOTIFY_CLIENT_ID!,
      clientSecret: process.env.SPOTIFY_CLIENT_SECRET!,
      authorization: `https://accounts.spotify.com/authorize?scope=${scopes}`,
    }),
  ],
  callbacks: {
    // NOTE: userはspotify側で自動で設定される(user.idはSpotifyのIDが設定される)
    async jwt({ token, account, user }: {
      token: JWT;
      account?: Record<string, any>;
      user?: Record<string, any>;
    }) {
      if (account && account.provider === "spotify") {
        console.log('token:', token);
        console.log('account:', account);
        console.log('user:', user);

        token.spotifyId = account.providerAccountId;
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.accessTokenExpires = Date.now() + account.expires_at * 1000;

        // 新規ユーザの場合、データベースにユーザを作成
        if (user) {
          await prisma.user.upsert({
            where: { spotifyId: user.id },
            update: {},
            create: {
              name: user.name || "",
              email: user.email || "",
              spotifyId: user.id,
              refreshToken: token.refreshToken as string,
            },
          });
        }
      }

      return token;
    },
    async session({ session, token }: {
      session: Session;
      token: JWT & { spotifyId?: string; accessToken?: string; refreshToken?: string };
    }) {
      if (token.spotifyId) {
        // NOTE: userにはNexAuthがデフォルトで提供するプロパティ(name, email, image等)が含まれる
        session.user.spotifyId = token.spotifyId;
      }

      return session;
    },
  },
  pages: {
    signIn: '/auth/signin',
  },
};
```

標準のセッションにSpotify IDを追加するため型定義をします。
```ts:src\app\next-auth.d.ts
import NextAuth from "next-auth";

declare module "next-auth" {
    interface Session {
        user: {
            name?: string | null;
            email?: string | null;
            image?: string | null;
            spotifyId?: string; // 追加したプロパティ
        };
    }
}
```

また、アプリ全体で使用するその他の型定義をします。
```ts:src\types\spotify.ts
export type Track = {
    id: string;
    name: string;
    artist: string;
    album: string;
    popularity: number;
    imageUrl: string;
    trackUrl: string;
};

export type Playlist = {
    id: string;
    name: string;
    description: string | null;
    imageUrl: string | null;
    tracks: Track[];
};

export type Device = {
    id: string;
    name: string;
};

export type CacheExpiry = {
    id: number;
    spotifyId: string;
    type: string;
    expiresAt: string; // Prismaから返ってくる日時はISO文字列のため
    createdAt: string; // Prismaから返ってくる日時はISO文字列のため
}

export type User = {
    id: string;  // SpotifyのユーザーID
    display_name: string; // ユーザー名
    email: string; // ユーザーのメールアドレス
    images?: { url: string }[]; // プロフィール画像のURL（オプション）
};

export type SpotifyData = {
    user?: User;
    topTracksIn4Weeks?: Track[];
    topTracksIn6Months?: Track[];
    topTracksInAllTime?: Track[];
    playlists?: (Playlist & { tracks: Track[] })[];
    devices?: Device[];
    savedTracks?: Track[];
}

export type FavoritePeriod = {
    id: number;
    favoriteId: number;
    startDate: string; // Prisma の DateTime 型は ISO 文字列として返される
    endDate?: string;  // nullable な場合はオプショナル
};

export type Favorite = {
    id: number;
    userSpotifyId: string;
    spotifyTrackId: string;
    spotifyTrackName: string;
    periods: FavoritePeriod[]; // 関連する期間のリスト
};
```

### Prisma
Prismaは、データベース操作を簡素化するORMツールです。Prismaを使ってPostgreSQLと接続します。Prisma CLIツールをインストールします。
```bash
npm install prisma --save-dev
```
```bash
npm install @prisma/client
```

Prismaの設定ファイルを初期化するコマンドです。このコマンドを実行すると、Prismaの設定ファイル（prisma/schema.prisma）やデータベース接続設定を含むprismaディレクトリがプロジェクトに作成されます。これにより、Prismaを使用したデータベースモデルの作成やマイグレーションが行えるようになります。
```bash
npx prisma init
```

データベースで使用するデータの設定を行います。
```prisma:prisma\schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id              Int      @id @default(autoincrement())
  name            String?
  email           String?  @unique
  spotifyId       String   @unique
  refreshToken    String
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  schedules  Schedule[]
}

model Favorite {
  id                    Int              @id @default(autoincrement())
  userSpotifyId         String
  spotifyTrackId        String
  spotifyTrackName      String

  periods               FavoritePeriod[]

  @@unique([userSpotifyId, spotifyTrackId]) // 複合一意制約
}

model FavoritePeriod {
  id         Int       @id @default(autoincrement())
  favoriteId Int
  startDate  DateTime
  endDate    DateTime?

  favorite   Favorite  @relation(fields: [favoriteId], references: [id])
}

model SpotifyCache {
  id         Int       @id @default(autoincrement())
  spotifyId  String
  type       String
  expiresAt  DateTime
  createdAt  DateTime  @default(now())

  @@unique([spotifyId, type])
}
```

`schema.prisma`の設定にもとづきデータベースを作成します。
```bash
npx prisma migrate dev --name init
```

アプリで使用できるように設定します。
```bash
px prisma generate
```

データベースをブラウザで管理することができます。以下のコマンドを入力して、
```bash
npx prisma studio
```
以下にアクセスします。

http://localhost:5555/

### Recoil
Recoilは、状態管理ライブラリで、Reactアプリケーションのデータの流れを管理します。Recoilをインストールします。
```bash
npm install recoil
```
コンポーネント間で共有する状態を設定します。
```ts:src\app\state\state.ts
import { atom } from "recoil";
import { Track, Playlist, Favorite } from "@/types/spotify"

export const savedTracksState = atom<Track[]>({
    key: "savedTracksState",
    default: [],
});

export const favoritesState = atom<Favorite[]>({
    key: "favoritesState",
    default: [],
});

export const tracksIn4WeeksState = atom<Track[]>({
    key: "tracksIn4WeeksState",
    default: [],
});

export const tracksIn6MonthsState = atom<Track[]>({
    key: "tracksIn6MonthsState",
    default: [],
});

export const tracksInAllTimeState = atom<Track[]>({
    key: "tracksInAllTimeState",
    default: [],
});

export const playlistsState = atom<Playlist[]>({
    key: "playlistsState",
    default: [],
});

export const selectedViewState = atom<"Chart" | "Top Tracks in 4 Weeks" | "Top Tracks in 6 Months" | "Top Tracks of All Time" | "Favorites">({
    key: "selectedViewState",
    default: "Chart",
});

export const selectedPlaylistState = atom<Playlist | null>({
    key: "selectedPlaylistState",
    default: null,
});

export const createPlaylistModeState = atom<boolean>({
    key: "createPlaylistModeState",
    default: false,
});
```

### Echarts
Echartsは、データを可視化するためのグラフライブラリです。Echartsをインストールします。
```bash
npm install echarts echarts
```

## Bootstrap Templatesの使用
Bootstrapテンプレートを使用して、アプリのデザインを迅速に整えることができます。

https://bootstrapmade.com/

ダウンロードしたファイルを`public\assets`に保存します（必要なものを抜粋、一部修正しています）。

## アプリ完成
完成したアプリのフォルダ構成となります。
```plaintext
project/
├── .next/                        
├── node_modules/                 
├── prisma/                       
│   ├── migrations/
│   └── schema.prisma             # データベース関係設定
├── public/                       
│   └── assets/                   # Bootstrapのテンプレートファイル
├── src/                          
│   ├── app/
│   │   ├── api/                  # バックエンドのAPIを作成
│   │   ├── components/           # 再利用するコンポーネントを作成
│   │   ├── favorites/            # お気に入りのページを作成
│   │   ├── fonts/
│   │   ├── playlists/            # プレイリストのページを作成
│   │   ├── top-tracks/           # トップトラックのページを作成
│   │   ├── global.css            
│   │   ├── layout.tsx            # アプリ全体のレイアウトを設定
│   │   ├── next.auth.d.ts        # NextAuth.jsで使用する型定義
│   │   └── page.tsx              # トップページ
│   ├── lib/                      # ユーティリティ関数を作成
│   └── types/                    # TypeScriptの型定義
├── .env                          # 環境変数ファイルを設定
├── .eslintrc.json                
├── next-env.d.ts                 
├── next.config.js                # Next.js設定
├── package-lock.json             
├── package.json                  
├── postcss.config.mjs            
├── README.md                     
├── tailwind.config.ts            
└── tsconfig.json          
```


##### 公開アプリ

http://tunetrek.iut-b.site:3000/

##### GitHub


https://github.com/IUT-b/music

次回、公開サーバーへ公開するまでを説明します。