今回はNext.jsとSupabaseのStorageを利用し、シンプルな『画像投稿アプリ』を作成します。
『画像投稿アプリ』は、一見作るのが簡単なように思えるかもしれませんが、何も考えずにただ『画像をデータ領域にアップロードするだけ』のアプリを作ってしまうと、
『高画質の画像ファイルでデータ領域が埋まってしまう』『プライベートな画像が流出する』『画像を開くのに時間がかかり、ユーザビリティが低下する』等、数々の問題が発生します。


そこで役に立つのが『Supabase Storage』です。これを利用することにより、『画像を適切に圧縮する』『ファイルアクセスの制御を細かく設定できる』『グローバルCDNにより高速で画像を届けることができる』等、数々のメリットを享受することが出来ます。


この記事では、オープンな画像・プライベートな画像、それぞれの投稿が可能なアプリの作り方をご紹介します。


事前準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していないければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。

適当なプロジェクト名とデータベースのパスワードを入れて、新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。


②:Next.jsプロジェクトの作成

任意のディレクトリで、

npx create-next-app -e with-supabase

と打ち込み、
対話的にアプリケーションの名前の設定をして、プロジェクトの作成を行いましょう。


次に環境変数の設定を行います。
作成されたNext.jsプロジェクトの直下に`.env.local.example`ファイルがあるので、
このファイルを`.env.local`にリネームし、
Supabaseのサイトの`Project Settings→API`にある、Project URLとAPI Keyをそれぞれ`.env.local`にコピペしてください。

# Update these with your Supabase details from your project settings > API# https://app.supabase.com/project/_/settings/apiNEXT_PUBLIC_SUPABASE_URL=Project URLNEXT_PUBLIC_SUPABASE_ANON_KEY=APIキー

その後、

npm run dev

を実行して添付のような画面が表示されればプロジェクトの作成成功です。

SupabaseでStorage作成

①:ストレージ作成

SupabaseのStorageにはPublicとPrivateの2種類がありますが、今回は両方作成して違いを確認していきます。


まずは、Supabaseのダッシュボードから`Storage`を選択し、`New Bucket`ボタンを押下します。
すると、添付画像のような画面が出てくるので、
名前に`public-image-bucket`、`Public Bucket`をONにします。

これで`Save`を押せばPublic Bucketが作成されます。
ONにしたときの注意書きにあるように、Public Bucketを利用する際はオブジェクトのアップロードや削除の際にRLSポリシーを設定する必要があるため、後ほど作成を行います。


次はPrivate Bucketを作成します。こちらは添付画像の様に
名前を`private-image-bucket`、`Public bucket`はOFFのままで作成します。


②:ポリシー作成

では、ここからStorageをNext.jsから利用できるようにポリシーの設定を行います。
今回は認証を行わずに投稿を可能にするため本来は良くないですが全てのアクセスを無条件で許可する設定にします。
※サービスを公開する際は、危険なので『認証ユーザのみに操作を絞る』などポリシーを適切に変更してください。


先程開いたSupabaseダッシュボードのStorageメニューの`Policies`をクリックします。
すると現在設定されているポリシーの一覧画面が表示されるため、
`Other policies under storage.objects`の右にある`New policy`ボタンを押下します。
すると下記のような画面が出てくるので`Get started quickly`を押しましょう。テンプレートから簡単にポリシーを作成できます。

その後`Enable read access for all users`を選択して次に進みます。

更に詳細に設定ができるため、デフォルトの`SELECT`から`ALL`にチェックを変更して、`WITHC CHECK expression`にもtrueを入れます。

終わったら同様の操作を`Policies under storage.buckets`にも行いましょう。


Next.js側の実装

①:必要なファイルの作成

今回作りたい画面に合わせて、プロジェクトのファイル構成を変更しましょう。
現状のファイル構成が下記のようになっているかと思いますが、まずは不要なファイルを削除し、必要なファイルと入れ替えて行きましょう。

不要なファイル削除&入れ替え後

削除するファイルは、

  • app/auth/callback/route.ts
  • app/auth/login/page.tsx
  • componentsフォルダの直下すべて
  • util/supabase直下すべて
  • middleware.ts

になります。


新たに作成するファイルは、

  • app/private/page.tsx
  • components/header.tsx
  • components/imageApp.tsx
  • components/privateImageApp.tsx

になります。(一旦ファイルを新規作成するのみで大丈夫です。中身は後で作ります)
多いので一個ずつ画像を見ながら作成してみてください。


では、各ファイルの中身を作成する前に下準備から行いましょう。
 

②:下準備

必要なファイルをいくつか作成・変更していきます。


utils/supabase/supabase.ts

Supabaseに共通でアクセスするためのクライアントを作成します。

import { createClient } from '@supabase/supabase-js'export const supabase = createClient(  process.env.NEXT_PUBLIC_SUPABASE_URL!,  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,);

.env.localに入力したURLとAPIキーがここで利用されます。


app/globals.css

tailwindcssのインポート以外は全て消しておきます。

@tailwind base;@tailwind components;@tailwind utilities;


uuid生成用パッケージの導入

下記のパッケージを、ファイル名のランダム生成用に利用します(一部の文字がSupabase Storageでファイル名として利用できないためこちらで生成しています)

下記2つのコマンドを実行してください。

npm i uuidnpm i --save-dev @types/uuid


③:基本ページの作成

Todoアプリとは関係ないですが、大元のレイアウトを作成します。(今回必要な内容ではないため、特に説明する部分はないです。)


app/layout.tsx

import Header from "@/components/header";import "./globals.css";const defaultUrl = process.env.VERCEL_URL  ? `https://${process.env.VERCEL_URL}`  : "http://localhost:3000";export const metadata = {  metadataBase: new URL(defaultUrl),  title: "Next.js and Supabase Starter Kit",  description: "The fastest way to build apps with Next.js and Supabase",};export default function RootLayout({  children,}: {  children: React.ReactNode;}) {  return (    <html lang="ja">      <body className="bg-background text-foreground">        <Header></Header>        <main className="min-h-screen flex flex-col items-center px-2">          {children}        </main>      </body>    </html>  );}


app/page.tsx

トップページ側の画像投稿アプリの表示を追加しています。

import ImageApp from "@/components/imageApp";export default function Index() {  return (    <>      <h1 className="mb-4 pt-28 text-4xl">画像投稿アプリ</h1>      <div className="flex-1 w-full flex flex-col items-center">        <ImageApp />      </div>    </>  );}

トップページの全体の見た目は下記のようになります。



④:画像投稿アプリ

画像投稿アプリ部分を作成します。
投稿部分と画像表示部分があるため、分けて実装の説明をします。


components/imageApp.tsx

"use client"import { supabase } from "@/utils/supabase/supabase"import { useEffect, useState } from "react"import { v4 as uuidv4 } from 'uuid'export default function ImageApp() {  const public_url = "https://jforbbjxywxfwurklxor.supabase.co/storage/v1/object/public/public-image-bucket/img/"  const [urlList, setUrlList] = useState<string[]>([])  const [loadingState, setLoadingState] = useState("hidden")  const listAllImage = async () => {    const tempUrlList: string[] = []    setLoadingState("flex justify-center")    const { data, error } = await supabase      .storage      .from('public-image-bucket')      .list("img", {        limit: 100,        offset: 0,        sortBy: { column: 'created_at', order: 'desc' },      })    if (error) {      console.log(error)      return    }    for (let index = 0; index < data.length; index++) {      if (data[index].name != ".emptyFolderPlaceholder") {        tempUrlList.push(data[index].name)      }    }    setUrlList(tempUrlList)    setLoadingState("hidden")  }  useEffect(() => {    (async () => {      await listAllImage()    })()  }, [])  const [file, setFile] = useState<File>()  const handleChangeFile = (e: any) => {    if (e.target.files.length !== 0) {      setFile(e.target.files[0]);    }  };  const onSubmit = async (    event: any  ) => {    event.preventDefault();    if (file!!.type.match("image.*")) {      const fileExtension = file!!.name.split(".").pop()      const { error } = await supabase.storage        .from('public-image-bucket')        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)      if (error) {        alert("エラーが発生しました:" + error.message)        return      }      setFile(undefined)      await listAllImage()    } else {      alert("画像ファイル以外はアップロード出来ません。")    }  }  return (    <>      <form className="mb-4 text-center" onSubmit={onSubmit}>        <input          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"          type="file"          id="formFile"          accept="image/*"          onChange={(e) => { handleChangeFile(e) }}        />        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">          送信        </button>      </form>      <div className="w-full max-w-3xl">        <div className={loadingState} aria-label="読み込み中">          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>        </div>        <ul className="flex flex-wrap w-full">          {urlList.map((item, index) => (            <li className="w-1/4 h-auto p-1" key={item}>              <a className="hover:opacity-50" href={public_url + item} target="_blank">                <img className="object-cover max-h-32 w-full" src={public_url + item} />              </a>            </li>          ))}        </ul>      </div>    </>  )}


投稿部分

画像投稿機能はまず下記のHTMLでUIが出来ています。

<form className="mb-4 text-center" onSubmit={onSubmit}>    <input      className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"      type="file"      id="formFile"      accept="image/*"      onChange={(e) => { handleChangeFile(e) }}    />    <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">      送信    </button></form>

ファイルが選択されると`input`の`onChange`に指定されている`handleChangeFile(e)`が実行され、下記で状態管理されている`file`に選択したファイルの情報が入ります。

const [file, setFile] = useState<File>()const handleChangeFile = (e: any) => {    if (e.target.files.length !== 0) {      setFile(e.target.files[0]);    }};

ファイルを選択した上で送信ボタンを押すと`onSubmit`が実行されます。

const onSubmit = async (    event: any  ) => {    event.preventDefault();        if (file!!.type.match("image.*")) {      const fileExtension = file!!.name.split(".").pop()      const { error } = await supabase.storage        .from('public-image-bucket')        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)      if (error) {        alert("エラーが発生しました:" + error.message)        return      }      setFile(undefined)      await listAllImage()    } else {      alert("画像ファイル以外はアップロード出来ません。")    }}

下記でSupabaseのStorageへのアップロードが行われます。

await supabase.storage        .from('public-image-bucket')        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)

また、アップロード成功後に画像表示のリロードをここで行っています。

await listAllImage()


画像表示部分

画像表示部分のUIのHTMLは下記の様な形です。
表示待機用のローディングスピナーと画像表示用に画像URLの一覧である`urlList`の要素数だけ画像を表示しています。

<div className="w-full max-w-3xl">    <div className={loadingState} aria-label="読み込み中">      <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>    </div>    <ul className="flex flex-wrap w-full">      {urlList.map((item, index) => (        <li className="w-1/4 h-auto p-1" key={item}>          <a className="hover:opacity-50" href={public_url + item} target="_blank">            <img className="object-cover max-h-32 w-full" src={public_url + item} />          </a>        </li>      ))}    </ul></div>

必要な変数や状態管理が下記です。
`public_url`は自分の環境に応じて適切なURLに変更してください。
試しにStorageに手動で画像を入れてみてURLを取得するとわかりやすいです。

const public_url = "https://jforbbjxywxfwurklxor.supabase.co/storage/v1/object/public/public-image-bucket/img/"const [urlList, setUrlList] = useState<string[]>([])const [loadingState, setLoadingState] = useState("hidden")

下記が画像表示のメインの実装です。
実行前にローディングを表示、実行後に非表示するようにしています。

const listAllImage = async () => {    const tempUrlList: string[] = []    setLoadingState("flex justify-center")    const { data, error } = await supabase      .storage      .from('public-image-bucket')      .list("img", {        limit: 100,        offset: 0,        sortBy: { column: 'created_at', order: 'desc' },      })    if (error) {      console.log(error)      return    }    for (let index = 0; index < data.length; index++) {      if (data[index].name != ".emptyFolderPlaceholder") {        tempUrlList.push(data[index].name)      }    }    setUrlList(tempUrlList)    setLoadingState("hidden")  }

Supabase側にアクセスして画像をリスト表示しているのが下記の実装部分です。
画像数を100に制限し、作成された日時の降順(新しい順)に表示するようにしています。

const { data, error } = await supabase      .storage      .from('public-image-bucket')      .list("img", {        limit: 100,        offset: 0,        sortBy: { column: 'created_at', order: 'desc' },      })

ここが躓くポイントですが、Storageの不要な隠しファイルも取ってきてしまうため下記の分岐部分で弾くようにしています。

for (let index = 0; index < data.length; index++) {      if (data[index].name != ".emptyFolderPlaceholder") {        tempUrlList.push(data[index].name)      }    }

初回の画像表示は下記で行っています。

useEffect(() => {    (async () => {      await listAllImage()    })()  }, [])


⑤:画像投稿アプリ(プライベート画像)

今度はPrivate Bucketに対しておこなう画像投稿アプリの実装を行います。


components/privateImageApp.tsx

"use client"import { supabase } from "@/utils/supabase/supabase"import { useEffect, useState } from "react"import { v4 as uuidv4 } from 'uuid'export default function PrivateImageApp() {  const [urlList, setUrlList] = useState<string[]>([])  const [loadingState, setLoadingState] = useState("hidden")  const listAllImage = async () => {    const tempUrlList: string[] = []    setLoadingState("flex justify-center")    const { data, error } = await supabase      .storage      .from('private-image-bucket')      .list("img", {        limit: 100,        offset: 0,        sortBy: { column: 'created_at', order: 'desc' },      })    if (error) {      console.log(error)      return    }    const fileList = data    for (let index = 0; index < fileList.length; index++) {      if (fileList[index].name != ".emptyFolderPlaceholder") {        const filePath = `img/${fileList[index].name}`        const { data, error } = await supabase.storage.from('private-image-bucket').createSignedUrl(filePath, 300)        if (error) {          console.log(error)          return        }        tempUrlList.push(data.signedUrl)      }    }    setUrlList(tempUrlList)    setLoadingState("hidden")  }  useEffect(() => {    (async () => {      await listAllImage()    })()  }, [])  const [file, setFile] = useState<File>()  const handleChangeFile = (e: any) => {    if (e.target.files.length !== 0) {      setFile(e.target.files[0]);    }  };  const onSubmit = async (    event: any  ) => {    event.preventDefault();    if (file!!.type.match("image.*")) {      const fileExtension = file!!.name.split(".").pop()      const { error } = await supabase.storage        .from('private-image-bucket')        .upload(`img/${uuidv4()}.${fileExtension}`, file!!)      if (error) {        alert("エラーが発生しました:" + error.message)        return      }      setFile(undefined)      await listAllImage()    } else {      alert("画像ファイル以外はアップロード出来ません。")    }  }  return (    <>      <form className="mb-4 text-center" onSubmit={onSubmit}>        <input          className="relative mb-4 block w-full min-w-0 flex-auto rounded border border-solid border-neutral-300 bg-clip-padding px-3 py-[0.32rem] text-base font-normal text-neutral-700 transition duration-300 ease-in-out file:-mx-3 file:-my-[0.32rem] file:overflow-hidden file:rounded-none file:border-0 file:border-solid file:border-inherit file:bg-neutral-100 file:px-3 file:py-[0.32rem] file:text-neutral-700 file:transition file:duration-150 file:ease-in-out file:[border-inline-end-width:1px] file:[margin-inline-end:0.75rem] hover:file:bg-neutral-200 focus:border-primary focus:text-neutral-700 focus:shadow-te-primary focus:outline-none"          type="file"          id="formFile"          accept="image/*"          onChange={(e) => { handleChangeFile(e) }}        />        <button type="submit" disabled={file == undefined} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">          送信        </button>      </form>      <div className="w-full max-w-3xl">        <button onClick={listAllImage} className="py-2.5 px-5 me-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 ">リロード</button>        <div className={loadingState} aria-label="読み込み中">          <div className="animate-spin h-10 w-10 border-4 border-blue-500 rounded-full border-t-transparent"></div>        </div>        <ul className="flex flex-wrap w-full">          {urlList.map((item, index) => (            <li className="w-1/4 h-auto p-1" key={item}>              <a className="hover:opacity-50" href={item} target="_blank">                <img className="object-cover max-h-32 w-full" src={item} />              </a>            </li>          ))}        </ul>      </div>    </>  )}

基本的にはPublic Bucketの画像投稿アプリの実装と変わらないのですが、画像表示の方式が違います。

Public Bucketのときは、画像名を取得し、バケットのディレクトリのURLと接続して画像のURLを作って渡していましたが、Private Bucketの画像はURLに直接アクセスすることが出来ません。
※アクセスしても下記のエラーが表示されます。

{"statusCode":"400","error":"Error","message":"querystring must have required property 'token'"}


Private Bucketの画像を利用するには、認証済みURLをその度に発行する必要があります。このURLにはトークンが付与され、そのトークンの有効期限までは画像の閲覧が可能になります。

今回は下記の部分で画像のファイルパスをもとに300秒(5分)の有効期限のURLを発行しています。

const { data, error } = await supabase.storage.from('private-image-bucket').createSignedUrl(filePath, 300)


プライベート画像側はこの仕様で利用するため、リロードボタン(有効期限のリセット)を追加して添付画像の見た目になります。

⑥:その他


components/header.tsx

画面上のヘッダー部分のUIです。

import Link from 'next/link'export default function Header() {  return (    <header className="p-4 border-b-2 border-gray-300 fixed w-full bg-white">      <ul className="w-full max-w-3xl m-auto flex font-medium flex-row">        <li className=' pr-4'>          <Link className="text-gray-700 hover:text-blue-700" href="/">Home</Link>        </li>        <li>          <Link className="text-gray-700 hover:text-blue-700" href="/private">画像投稿アプリ(プライベート画像)</Link>        </li>      </ul>    </header>  )}


挙動確認

画像投稿アプリの作成は完了です。

npm run dev

を行い、実際に触ってみましょう。

`http://localhost:3000`にアクセスします。
すると、下記のUIが現れます。
(まだ画像は追加していないため、何も表示されません。)

ファイルを選択すると送信ボタンが押せるようになります。
 

送信を押すとローディングが表示された後に画像が表示されます。

画像をクリックすると原寸大の元画像が表示されます。

SupabaseのStorageを見ると画像が追加されているのが確認できます

次にプライベート画像側を利用してみます。
こちらも同じ用に画像ファイルを送信すると画面上に表示されます。

画像をクリックすると原寸大の画像を見ることが出来ますが、
`?token=`以降の文字列を削除すると閲覧が出来なくなります。

こちらも同じく、SupabaseのStorageに画像が追加されているのが確認出来ます。


参考資料

今回の記事のコード全体はこちらです。


また、TodoONada株式会社ではNext.js・Supabase等を利用したアプリの作り方を、各種ご紹介しております。
ぜひこちらも御覧ください。



お問合せ&各種リンク

お問合せ:GoogleForm

ホームページ:https://libproc.com

運営会社:TodoONada株式会社

Twitter:https://twitter.com/Todoonada_corp

Instagram:https://www.instagram.com/todoonada_corp/

Youtube:https://www.youtube.com/@todoonada_corp/

Tiktok:https://www.tiktok.com/@todoonada_corp

presented by