【Next.js14】×【GCS】最新のNext.js機能AppRouterに対応した非公開GCSからの画像取得を実装してみました

最新のNext.js機能App Routerに対応したGCSからのデータ取得の記事が見つからなくて困ったので書いてみました！
すでにGCS上にデータのアップロードが完了しているところからの説明になります。参考になれば嬉しいです！

# この記事でわかること
最新のNext.js機能App Routerに対応した
* APIルートの作り方
* リクエストBodyの書き方
* 非公開GCS(Google Cloud Storage)から認証してもらう方法

# １.APIルートを作る
### 1-1.APIルートを作るためのフォルダを用意する
AppRputerではこれまでと違い、下記のようなフォルダ構成にするとAPIルートになります
```app/api```フォルダ内に任意のフォルダを作り、そのフォルダの名がAPIからデータを取るときのURL名になります(今回は```getProfImage```)
フォルダに保管するデータは共通で```route.js```にするところもこれまでと違いますね
```
my-app  //名前は自由につけていいです//
├─ src
│   ├ app
│      ├ api
│        └ getProfImage  //名前は自由につけていいです//
│           └ route.js
│   ├ page.js
│   └ favicon.ico
│
├─ .gitignore
├─ package.json 
├─ package-lock.json
└─ README.md
```

### 1-2.route.jsを書く
ここはサーバーサイドでGCRにアクセスするための有効期間を指定してSigned URLを生成する機能を持ちます
サーバーサイドなのでより安全にURLを管理できるってことですね
環境変数は後で設定します



```/src/app/api/getProfImage/route.js
import { NextResponse } from "next/server";
import { Storage } from '@google-cloud/storage';

export async function GET(request) {
  const urlParams = new URL(request.url).searchParams;
  const fileName = `items/${urlParams.get('file')}`;

  if (!fileName) {
    return new NextResponse(JSON.stringify({ error: 'File name is missing' }), { status: 400 });
  }

  const storage = new Storage({
    projectId: process.env.GCS_PROJECT_ID,
    keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON,
  });

  const bucketName = process.env.BUCKET_NAME ?? '';
  const bucket = storage.bucket(bucketName);
  const file = bucket.file(fileName);

  try {
    const options = {
      version: 'v4',
      action: 'read',
      expires: Date.now() + 5 * 60 * 1000, // 5 minutes from now
    };
    const [url] = await file.getSignedUrl(options);
    return new NextResponse(JSON.stringify({ url }), { status: 200 });
  } catch (error) {
    console.error('Failed to create signed URL:', error);
    return new NextResponse(JSON.stringify({ error: `Failed to create signed URL: ${error.message}` }), { status: 500 });
  }
}

```

:::note warn
```NextResponse```を使う、```GET```メソッドを明示する点がこれまでと違います
:::
#### 　1-2-2.従来からの変更点解説(コピペだけしたい人はスルーして大丈夫です！)
* 従来
```/pages/api/getProfImage.js
export default function handler(req, res){
  ...
  return res.status(200).json({url})
}
```
* Approuter対応
　　```GET```メソッドが明示され、```res```が```NextResponse```に置き換えられていますね
```/src/app/api/getProfImage/route.js
export async function GET(request) {
  ...
  return new NextResponse(JSON.stringify({ url })
}
```

### 1-3.next.config.mjsに追記
GCSから画像を取ってくることを許可するために下記を追記します
```/next.config.mjs
  images: {
    domains: ['storage.googleapis.com'],
  },
```
### 1-4.@google-cloud/storage モジュールがプロジェクトにインストール
下記コマンドをターミナルで叩き @google-cloud/storage パッケージをインストールします

```
  npm install @google-cloud/storage
```

# ２.データを取ってくるサービスアカウントを準備する
GCPのページでデータを取得する権限のあるサービスアカウントをつくります
データを取ってきたいGCSのバケットのページ左上のハンバーガーアイコンをクリック

<img width="150" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/3626407c-0b37-e4ed-457b-f6a23fc62af1.png">

IAMと管理＞サービスアカウント＞をクリック
<div>
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/332a8fff-d091-7e43-c289-181aca4ae140.png">
</div>

画面上部のサービスアカウントを作成をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/4d98d664-3fc3-77fe-80c4-e21ba6463927.png)

好きな名前とIDを入力

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/9f5d70c4-dcbc-1416-42fa-1f91738f813f.png)

### ②このサービス アカウントにプロジェクトへのアクセスを許可する
でこのアカウントに付与したい権限のロールを選択します
PJの規模・運用方法に合わせて選択してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/dfc9eea9-7da6-cdb6-80c5-087346d10114.png)

その他はスルーして、認証キーをダウンロードします
作成したアカウントの```キー```タブにある```鍵を追加```をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/8b7f3c92-bf5f-afc0-c3d7-16664774fbc8.png)

◉　JSON形式を選択して```作成```をクリックしキーをダウンロードします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790276/8ed4aeca-cb72-7219-21e4-feb77193847c.png)

```gcs_credential.json```と名前をつけて```.env.local```と同じ階層に保管し
```.gitignore```に追加します

```/.gitignore
# local env files
.env*.local
gcs_credential.json
```

:::note alert
キーは大切なデータなので絶対に.gitignoreに入れるのを忘れないでください
:::

# ３..env.localに必要な情報を入れる

```BUCKET_NAMEとGCS_PROJECT_ID```はPJによって違うので下記記載の通りに書いてみてください

```
BUCKET_NAME=バケットの名前を記載
GCS_PROJECT_ID=　gcs_credential.json内にある"project_id"を記載
GOOGLE_APPLICATION_CREDENTIALS_JSON=./gcs_credential.json
```

:::note info
.env.local内に記載する文字列は```""```や```’’```は不要で直接記載します
サーバーサイドで使用するキーなので```NEXT_PUBLIC_```は付けないでください
:::

# ４.画像表示用のクライアントサイドのファイルを作る
ここまできたらあとは画像を表示するだけです！
先ほどサーバーサイドの```route.js```で作った時間制限のあるURLを使ってGCSにアクセスして画像を表示します



下記に全体のコードを示しますが、一例なのでPJに合わせて書き換えてみてください

```/src/components/GCSImageLoader.js
import { useEffect, useState } from 'react';
import Image from 'next/image';

const fetchSignedUrlFromGCS = async (itemId, folder) => {
  const fileName = `${folder}/${itemId}.png`;
  const url = `/api/getProfImage?file=${encodeURIComponent(fileName)}`;

  try {
    const response = await fetch(url, {
      headers: {
        'Cache-Control': 'max-age=3600',
      },
    });
    if (!response.ok) {
      throw new Error(`Failed to load image with status: ${response.status}`);
    }
    const data = await response.json();
    return data.url;
  } catch (error) {
    console.error('Error fetching signed URL:', error);
    throw new Error(`Error fetching signed URL: ${error.message}`);
  }
};

const GCSImageLoader = ({ imageUrl, error, width, height, objectFit = 'cover',quality , loadingStrategy}) => {
  return (
    <div style={{ position: 'relative', width: `${width}px`, height: `${height}px`, overflow: 'hidden' }}>
      {error && <p>Error loading image: {error}</p>}
      <Image
        src={imageUrl}
        alt={error ? "Failed to load image" : "Product Image"}
        quality={quality}
        placeholder="blur"
        sizes="10vw"
        fill
        blurDataURL="/static/img/default-image.webp"
        loading={loadingStrategy}
        style={{
          objectFit: objectFit,
          objectPosition: 'center',
          width: '100%',
          height: '100%',
        }}
      />
    </div>
  );
};

const ParallelImageLoader = ({ items, folder, width, height, quality ,index ,objectFit }) => {
  const [images, setImages] = useState([]);

  useEffect(() => {
    const fetchImages = async () => {
      const promises = items.map(async (item) => {
        try {
          const url = await fetchSignedUrlFromGCS(item.id, folder);
          return { id: item.id, url, error: null };
        } catch (error) {
          return { id: item.id, url: '/static/img/default-image.png', error: error.message };
        }
      });

      const results = await Promise.all(promises);
      setImages(results);
    };

    fetchImages();
  }, [items, folder]);

  const loadingStrategy = index < 8 ? 'eager' : 'lazy';

  return (
    <>
      {images.map((image) => (
        <GCSImageLoader
          key={image.id}
          imageUrl={image.url}
          error={image.error}
          width={width}
          height={height}
          quality={quality}
          loading={loadingStrategy}
          objectFit= {objectFit}
        />
      ))}
    </>
  );
};

export { GCSImageLoader, ParallelImageLoader };
```

:::note info
共通のポイント
URLに記載している```getProfImage```は```route.js```を入れているフォルダ名にします
```/src/components/GCSImageLoader.js
const url = `/api/getProfImage?file=${encodeURIComponent(fileName)}`;
```
:::
:::note warn
PJごとに変える必要のある設定

* 今回は保管しているデータ形式が```.png```なので拡張子を指定しています。
* 別ルートでバックからもらっているitemIdに紐づけてデータを取ってこれるように、ファイル名はitemId.pngとして保管してもらっているので変数```${itemId}.png```として記載しています
```/src/components/GCSImageLoader.js
const fileName = `${itemId}.png`;
```
* 画像を最適化できるNext.jsの機能を使用したいので```<Image>```タグを使用していますが、```<image>```でも問題なく動きます
```/src/components/GCSImageLoader.js
import Image from 'next/image';
・・・
        <Image
        src={imageUrl}
        alt={error ? "Failed to load image" : "Product Image"}
        quality={quality}
        placeholder="blur"
        sizes="10vw"
        fill
        blurDataURL="/static/img/default-image.webp"
        loading={loadingStrategy}
        style={{
          objectFit: objectFit,
          objectPosition: 'center',
          width: '100%',
          height: '100%',
        }}
        />
```

:::


# 参考にさせていただいたサイト
皆さんの情報を参考にさせていただきました。
本当にわかりやすい記事で感動しました、この場を借りてお礼させていただきたいです。ありがとうございます。

[Next.js Appフォルダでの変更点を20個、時短で紹介](https://monotein.com/blog/nextjs-app-folder-new-things-list)
[Next.jsからgcsに画像を登録したり呼び出したりしたい！！！！](https://qiita.com/ryuji_vlog/items/b21c2bf258cae9849c1d)


# 最後に
最後まで読んでいただきありがとうございます！
AppRouter移行後の情報が少なく、悩んでしまったので記事にしてみました
少しでも参考になれば嬉しいです。