【Next.js × Cloudflare】結婚式のアルバムに入りきらなかった写真をWebアルバムにした話

**今年の5月に結婚式をしました** :tada: 

結婚式の前には、[Web招待状](https://zenn.dev/anneau/articles/05c22f560c3fad)も作り、やりきった気持ちでいましたが、気づけばもう年末が近づいているではないですか

年末、実家に帰ったら

:woman_tone1:  アルバムは？

と母親に聞かれるに決まっております。
ということで、重い腰を上げてアルバム作りに勤しみました。

とりあえず、紙媒体のアルバムも作るとして、せっかくなので、入りきらない写真たちは**Webアルバムにしてみようかなという企画でございます。**

## どんなものを作ったか

結婚式の写真は流石に恥ずかしいので、我が家の猫に出場してもらいます。

![gallery.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136353/2f4b599b-2740-dda8-2b2d-36ea854a5dc9.gif)

画像を30枚ずつフェッチする形にしていますが、結婚式の画像はある程度良い画質のもので提供したいので、データフェッチが終わるまではブラーを表示するようにしています。

あとは、そこまで凝ったことはしていませんが、30枚を読み込んだあと、普通にボタンを押してもらうとさらに読み込むような形にしています。


## 使用技術

- `Next.js v14.0.3`
- `Cloudflare Pages`
- `Cloudflare Images`

フロントエンドには慣れているので、`Next.js`を使用しました。
あとは、Next.jsをどうホスティングするかですが、できる限り料金を下げたかったことと、画像数が1000枚を超えそうだったので、Vercelではなく、Cloudflareでホスティングすることにしました。

## 工夫ポイント

### 1. Cloudflare Pagesへのデプロイ

以下のライブラリを用いました。

https://github.com/cloudflare/next-on-pages

概ね以下のドキュメントの通りなので、大きなつまりポイントなくリリースすることができました。

https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/

### 2. Cloudflare Imagesでの画像最適化

Cloudflare Imagesは勝手にWebPへの変換や、リサイズを行なってくれるため、とても便利です。

Cloudflare Imagesにアップロードした画像は以下のようなAPIで一覧が取得できるため、Next.jsのサーバーサイドでデータを一覧データを30件ずつ取得するようにしました。

```ts
  const { searchParams } = new URL(request.url)
  const continuationToken = searchParams.get('continuation_token')
  const perPage = searchParams.get('per_page')
  const params = {
    per_page: perPage || '30',
    ...(continuationToken && { continuation_token: continuationToken }),
    sort_order: 'desc',
  }
  const res = await fetch(
    `${process.env['CLOUDFLARE_URL']}/images/v2?${new URLSearchParams(params)}`,
    {
      headers: { Authorization: `Bearer ${process.env['CLOUDFLARE_API_KEY']}` },
      cache: 'no-cache',
    }
  )
  if (!res.ok) {
    throw new Error('Failed to fetch image urls')
  }
  const json = await res.json()
  let parsed
  try {
    parsed = Schema.parse(json)
  } catch (err) {
    return NextResponse.json({}, { status: 404 })
  }
```

https://developers.cloudflare.com/api/operations/cloudflare-images-list-images-v2?schema_url=https%3A%2F%2Fraw.githubusercontent.com%2Fcloudflare%2Fapi-schemas%2Fmain%2Fopenapi.yaml

:::note warn
一気に10000件まで取得できるようですが、後続の処理でブラー画像をフェッチするために、サブリクエストを発行する必要があり、Cloudflare Workersの制限に引っかかってしまうため、今回は30件ずつ取得する形で実装しています。
:::

フロントエンド側はLoaderを設定し、next/imageでの最適化は行わず、Cloudflare側に画像の最適化を直接してもらうようにしています。

```tsx
const loader: ImageLoader = ({ src }) => {
  return `https://imagedelivery.net/##account_identifier##${src}`
}

...
  <div className="grid gap-4">
    {items.map((image) => {
      return (
        <Image
          key={image.id}
          className="h-auto max-w-full rounded-lg"
          src={`/${image.id}/public`}
          loader={loader}
          width={1366}
          height={768}
          alt={String(image.id)}
        />
      )
    })}
  </div>
```

:::note warn
本当は、レイアウトシフトを防ぐため、ちゃんとwidth、heightを指定する必要があります。
今回、そこまでAPIを作り込めず、レイアウトシフトが発生しておりますが、またどこかで修正しようと思います。
:::

### 3. ブラー画像の表示

今回、画像が読み込めるようになるまでの間、ブラーのかかった画像を表示したかったので、`Variants`という機能を使って、ブラー画像を生成するようにしました。

管理画面上で設定できますし、どうせブラーのかかった状態で表示するので、とても小さい画像で生成するようにしました。

![スクリーンショット 2023-12-11 23.15.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136353/cf1c9722-0452-f507-924c-28312a31afd7.png)

また、[`next/image`では、ブラー画像URLをbase64にエンコードしたデータとして渡す必要がある](https://nextjs.org/docs/pages/api-reference/components/image#blurdataurl)ため、Cloudflareから画像の一覧をフェッチしたあとに、ブラー画像を`Promise.all`で取得、base64のURLに変換しています。

```ts
const cache = new Map<Props, string>()

async function getBase64ImageUrl(image: Props): Promise<string> {
  let url = cache.get(image)
  if (url) {
    return url
  }
  const response = await fetch(
    `https://imagedelivery.net/##account_identifier##/${image.id}/blur`
  )
  const buffer = await response.arrayBuffer()

  url = `data:image/jpeg;base64,${Buffer.from(buffer).toString('base64')}`
  cache.set(image, url)
  return url
}

...

const blurImagePromises = parsed.result.images.map((image) => {
  return getBase64ImageUrl({ id: image.id })
})

const imagesWithBlurDataUrls = await Promise.all(blurImagePromises)
```

あとは、この生成したURLを`blurDataURL`オプションとして渡し、`placeholder`オプションを`blur`に設定することで、画像取得中、ブラー画像を表示してくれます。

```tsx
...
  <div className="grid gap-4">
    {items.map((image) => {
      return (
        <Image
          key={image.id}
          className="h-auto max-w-full rounded-lg"
          src={`/${image.id}/public`}
          blurDataURL={image.blurImageUrl}
          placeholder="blur"
          loader={loader}
          width={1366}
          height={768}
          alt={String(image.id)}
        />
      )
    })}
  </div>
```


## まとめ

いかがでしたでしょうか？

今回は`Cloudflare Pages`でNext.jsをホスティングし、Cloudflare Imagesを用い、画像のリサイズやブラー処理を行いました。

クライアントからの情報に応じて、自動的に画像をリサイズしたりする場合は、`Cloudflare Image Resizing`だったり、`Flexible Variants`とかを使うとできたりしそうなので、またそれも使ってみたいなと思います。

ただ、その前にレイアウトシフトを無くしたり、無限スクロールを実装したりと年末までにもう少しブラッシュアップしようかと思うので、また気合があれば記事を書こうかと思います。