【個人開発】回答すると植物が育つアンケートサービスを作りました

# はじめに
はじめまして、たつやと申します。

突然ですが皆さん

カレーは辛さは何派ですか？私は中辛です。
朝は何を食べますか？私はパンです。

このように何派か知りたいことであったり、もっとニッチなことについて他の人がどのようにしているのか・感じているのかを知ることができる**Bloom Survey**というシンプルなアンケートサービスを開発・リリースしました。

実際のアンケートが以下です。

🍛カレーは辛さは何派？

https://bloomsurvey.com/pot/cm7m1yu6z0001s60dtwz0rrlk

🥣朝は何食べる？

https://bloomsurvey.com/pot/cm7nddmuy0001s60d3yrg8axf

機能は制限されますが、無料で登録無しでも使えるのでぜひ触ってみてください！

# 目的
`Tailwind CSS`を勉強したので、そのアウトプットです。
最近（でもないですが）`Tailwind CSS`の公式に載っているクラスを一通り動かして本にまとめました。この知識を活かしてどこまで実装できるのか挑戦してみました。

本にまとめたものはこちらです。

https://zenn.dev/tacchan5424/books/22d87ed6bc8550

:::note alert
現在`Tailwind CSS`は`v4.0`が最新です。この本とこの記事では`v3.4.17`を扱っているため、`v4.0`では一部動作しないものがあるかもしれません。
:::

題材がないとアウトプットできないので、Xのアンケート機能に注目しました（正直なんでもよかったので当時目についたものにしました）。Xのアンケート機能で過去に作成したアンケートや投票したアンケートの結果は、過去のポストを遡る必要があります。
そのため**作ったアンケートと投票したアンケートを一覧化して確認できるようなサービス**を作ろうと考えました。

# 技術選定
あまりお金をかけずに勉強できる構成の一例として参考程度に見てください。因みにですが2月は開発のみで21円かかりました。

:::note warn
もしかするとアクセス数増加等に伴い、コスパの悪い構成になってしまっているかもしれません。これまでアクセス数が多くなったサービスが無く、そこまで考えられていないためです。
:::

### フロントエンド
**★ [React](https://ja.react.dev/)**
`Tailwind CSS`と言えばみたいなところがあったので採用しました。いくつか開発してきた中で多く採用していて、少し慣れていることも理由です。しかし小規模な開発にとどまっていることもあり、知らないことが多くある印象です。勉強のため`Tailwind CSS`と同様に本にまとめてみようかなと考え中です。

<details><summary>余談</summary>

`create-react-app`がとうとう非推奨になりましたね。

https://github.com/facebook/create-react-app/pull/17003
</details>

**★ [Tailwind CSS](https://tailwindcss.com/)**
今回の開発のメインです。時間的な制約があり、少々作りこみが甘い感が否めません。ただ、概ね思ったように実装できたので勉強した成果をかなり実感しました。

**★ [TypeScript](https://typescriptbook.jp/#:~:text=TypeScript%E3%81%A8%E3%81%AF%E2%80%8B,%E3%82%AA%E3%83%BC%E3%83%97%E3%83%B3%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%A7%E5%85%AC%E9%96%8B%E3%81%97%E3%81%9F%E3%80%82)**
2年ぶりくらいに触りました。`any`を使わない縛りをしましたが私にはまだ早かったです。ちょこちょこと使ってしまっています。

### バックエンド
**★ [Next.js](https://nextjs.org/)**
夏頃使っていて記憶に新しいので採用しました。次回以降、他フレームワークも使ってみたいです。

**★ [Prisma](https://www.prisma.io/)**
初めて使いました。
Supabaseを操作するために採用しました。かなり簡単でよかったです。次も使いたいです。

### DB
**★ [Supabase](https://supabase.com/)**
初めて使いました。
これまで個人開発は`MongDB`や`DynamoDB`を触っていました。`No SQL`に慣れず、両者ともイマイチだったので今回は`Supabase`を採用しました。今のところはとても良いです。次も使いたいです。

### 認証
**★ [NextAuth](https://next-auth.js.org/)**
`Next.js`を使っていること、過去に使ったことがあるため採用しました。実装は問題なかったのですがデプロイ時に問題が発生したためサーバーの変更を余儀なくされました。

古いですがこちらも本にまとめています。

https://zenn.dev/tacchan5424/books/d1a857f228afdf

### サーバー
**★ [Cloud Run](https://cloud.google.com/run?hl=ja)**
初めて使いました。
サービスを載せるサーバだけではなくログ周りもまとめてしまいたいと考えた結果`GCP`にたどり着きました。（`AWS`は以前使ったことがあったので今回は選定の対象外にしました。）

候補として`Google App Engine`と`Cloud Run`で悩みましたが`Cloud Run`を採用しました。簡単に調べた限りだと`Cloud Run`の方が色々柔軟だったためです。

<details><summary>余談</summary>

初詣で引いたおみくじが大吉で「思うがままに成し遂げる。」みたいなことが書いていました。これが本当ならこのサービスはバズるだろう思い、下心もりもりで柔軟な方を採用しました。
</details>


### CI/CD
**★ [Cloud Build](https://cloud.google.com/build?hl=ja)**
初めて使いました。
`Dockerfile`に従ってソースコードをビルドして、コンテナイメージを作成します。

### コンテナ管理
**★ [Artifact Registry](https://cloud.google.com/artifact-registry/docs?hl=ja)**
初めて使いました。
`Cloud Build`で作成したコンテナイメージを管理しています。このコンテナイメージを`Cloud Run`で設定してデプロイしています。

### ログ
**★ [Cloud Logging](https://cloud.google.com/logging?hl=ja)**
初めて使いました。
ログ取得のため採用しています。

### ストレージ
**★ [Cloud Storage](https://cloud.google.com/storage?hl=ja)**
初めて使いました（まだ使っていません）。
稼働してから日数が経っていたいのでまだ稼働していませんがログ保管用です。一定期間が経ったらログを移行させる想定です。

# サービス概要
**Bloom Survey**という名前のアンケートサービスです。知りたい物事や質問を鉢、各選択肢を苗、投票することを水をあげることに見立てました。

既に遷移して見ていただいたかもしれませんが、鉢が「カレーの辛さは何派？」、苗が「甘口/中辛/辛口」にあたります。水をあげた苗は花が咲きます。水をあげた数は水滴の中の数字でも確認できますが、多いほど苗が大きくなっています。

![](https://storage.googleapis.com/zenn-user-upload/8afa5080bea0-20250223.png)

鉢にはサービス上で全ユーザが閲覧して水をあげることができる**一般公開**と、リンクを知っている人だけが閲覧して水をあげることができる**限定公開**があります。そのため例に挙げた「カレーの辛さは何派？」のようなことだけではなく、飲み会の予定のようなプライベートなことにも利用できます。
一般公開はホーム画面に表示され、また検索の対象となります。限定公開はホーム画面に表示されず、検索の対象とならないため基本的にはリンクからしか遷移できません。

苗は3つまで植えることができます。アンケートであれば5つくらい苗を植えられる方が便利ですが、特にモバイルでの見た目が崩れてしまうため断念しました。見た目や雰囲気を重視した結果、私の力では3つが限界でした。

また、機能は制限されてしまいますが`Cookie`を使用することで未登録状態でも利用できます。私の感覚ではよくわからないサービスに登録するのはかなりハードルの高いことなので、一旦少しでも触ってもらいたいなと思い実装しています。

# このサービスで出来ること
細かい機能はいくつかありますがメイン部分はシンプルで最小限にしています。

* **鉢を作って苗を植える**
* **水をあげる**
* **作った鉢を確認する**
* **水をあげた鉢を確認する**

これだけです。

簡単に紹介させてください。

## 鉢を作って苗を植える
ヘッダーにある「Plant」ボタンを押下することでモーダルが開きます。
![](https://storage.googleapis.com/zenn-user-upload/1313d7e2f43a-20250223.png)

ここで鉢や苗の名前、タグを設定して「Let's Plant」ボタンを押下することで鉢を作って苗を植えることができます。右上のスイッチを切り替えることで一般公開するか、限定公開にするか切り替えることができます。ただし、**一般公開はこのサービスに登録したユーザのみ**の機能です。未登録ユーザは**自動的に限定公開**として作成されます。

私が鉢を作るときに忘れてしまいがちなのですが、アイコンも変更することができます。
![](https://storage.googleapis.com/zenn-user-upload/87f09e4e3213-20250223.png)

## 水をあげる
ホーム画面や検索結果の一覧あるいはリンクから鉢の画面を開きます。苗上部の水滴をクリックすることで水をあげることができます。水をあげた苗は少し成長して花が咲きます。

![](https://storage.googleapis.com/zenn-user-upload/44ab15823898-20250224.png)

## 作った鉢を確認する
登録ユーザ限定の機能です。
ダッシュボードから確認することができます。
![](https://storage.googleapis.com/zenn-user-upload/7cf8d4d22968-20250301.png)

## 水をあげた鉢を確認する
同じく登録ユーザ限定の機能で、ダッシュボードから確認することができます。
![](https://storage.googleapis.com/zenn-user-upload/6a42e23eb5d0-20250301.png)

# 実装備忘録
ここから個人的に次回の参考になりそうなことについていくつか残します。

## Cloudflare Workersにデプロイできない事件
当時[Cloudflare Workers](https://www.cloudflare.com/ja-jp/developer-platform/products/workers/)にデプロイする予定で準備を進めていて、いざ作業に入るとエラーを吐いてデプロイできませんでした。

原因は`Cloudflare Workers`で使っている`Edge Runtime`が`NextAuth`と互換性がないことです。解決するためには`NextAuth`のバージョンを上げて`v5`にする必要があるとのことで、追加改修やセキュリティ的な観点的に`Cloudflare Workers`へのデプロイを諦めました。

https://github.com/nextauthjs/next-auth/issues/7527


## DBにユーザ情報を登録しない
このサービスではGoogleアカウントを使用してユーザ登録をするものの、Googleアカウントに紐づく名前やメールアドレスといった個人情報を登録していません。アンケートを作って投票する、ということに対して「誰が」という個人情報の必要性を感じなかったためです。
以下のように`NextAuth`のサインイン処理でユーザ情報を削除しています。

```react
export const authOptions: NextAuthOptions = {
  /*
    NextAuthで必要な設定
  */
  callbacks: {
    async signIn({ user }) {
      delete user.email;
      delete user.name;
      delete user.image;
      return true;
    },
  },
};
```

## 画像のキャッシュ利用
いくつも画像を使っているわけではないですが、毎度リクエストを投げているといくらお金がかかるかわからないのでキャッシュを利用するようにしています。
以下サイトの通り`Image`タグの`src`に画像パスを記載するのではなく、モジュールを設定してあげます。

https://zenn.dev/chot/articles/next-image-cache-control

## レスポンスヘッダ設定
以下記事で説明されている内容に[X-Content-Type-Options](X-Content-Type-Options)を加えて、以下例のようにしています。

https://zenn.dev/yutoo89/articles/d1ed72b821b2c0

```react
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          {
            key: "Strict-Transport-Security",
            value: "max-age=63072000; includeSubDomains; preload",
          },
          {
            key: "X-Frame-Options",
            value: "SAMEORIGIN",
          },
          {
            key: "Referrer-Policy",
            value: "strict-origin-when-cross-origin",
          },
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
        ],
      },
    ];
  },
};

export default nextConfig;
```

## カードのリンク実装
以下のようなカードを実装しました。
![](https://storage.googleapis.com/zenn-user-upload/ed40ea67ec6b-20250226.png)

カードをクリックした場合は鉢の画面へ、タグをクリックした場合はタグ検索をします。このように実装するにあたって、以下例のように`a`タグを入れ子に実装して終わりのつもりだったのですがエラーになりました。どうやら`a`タグは入れ子にしたらダメみたいです。

```html
<!-- このままではエラーが発生してしまう -->
<a href="/鉢画面への遷移用URL">
    <a href="/タグ検索用URL">タグ1</a>
    <a href="/タグ検索用URL">タグ2</a>
    <a href="/タグ検索用URL">タグ3</a>
</a>
```

そんな時にちょうど記事を書いてくださっている方がいて非常に助かりました。詳細は以下記事をご確認ください。一応`Tailwind CSS`で修正した例を残します。

https://zenn.dev/zozotech/articles/9e7b9059f15509

```html
<div className="relative">
  <!-- カードのリンク領域をカード全体に広げる -->
  <Link href="/" className="absolute inset-0 z-0">カード全体のリンク</Link>
  <!-- タグのリンクをカードリンクの上に重ねる -->
  <Link href="/" className="relative z-10">タグのリンク</Link>
  <Link href="/" className="relative z-10">タグのリンク</Link>
  <Link href="/" className="relative z-10">タグのリンク</Link>
</article>
```

## Xへの投稿機能
リンクのコピーだけではなくXについては直接ポストできるようにしています。あっさり実装できました。以下のように実装しています。

```react
<Link
 target="_blank"
 rel="noopener"
 href={`https://twitter.com/intent/tweet?hashtags=bloomsurvey&text=新しく苗を植えました！%0A&url=${url}%0A`}
>
```

`href`属性を分解してみます。

* `https://twitter.com/intent/tweet`：ベースのURLです。Xのポスト画面が立ち上がります。
* `hashtags=bloomsurvey`：ハッシュタグを設定します。`#bloomsurvey`というハッシュタグを作成しています。
* `text=新しく苗を植えました！`：テキストを挿入します。
* `%0A`：改行を挿入します。
* `url=${url}`：URLを設定します。ここでは鉢のリンクを設定しています。

実行するとこんな感じになります。
![](https://storage.googleapis.com/zenn-user-upload/737bd271501f-20250301.png)

https://blog.stin.ink/articles/put-twitter-share-button

## Cookie利用
記載済ですが未登録ユーザについてもサービスを利用できるように`Cookie`を使用しています。`next/headers`を使って以下のように実装しています（あくまでもイメージです）。

```react
import { cookies } from "next/headers";

export async function POST(request: NextRequest) {
  // パラメータとしてユーザIDをもらう体
  const params = await request.json();

  // Cookie確認
  const cookieStore = await cookies();
  // ユーザIDというkeyに対してCookieを設定する体
  let userId = cookieStore.get("ユーザID")?.value;
  // ユーザ登録されていなくてユーザID用Cookie存在しない場合、Cookieを発行
  if (!params.userId && !userId) {
    userId = crypto.randomUUID();
    cookieStore.set({
      name: "ユーザID",
      value: userId,
      path: "/",
      maxAge: 60 * 60 * 24 * 365,
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
    });
  }
}
```

フロント側では参照できない点に注意が必要です。

## 画像類のSEO対策
（私の調査不足な気がしますが）基本は調べて出てくる通りに設定すれば問題ないのですが1点だけ詰まりました。サービスアイコンが表示されない問題です。以下の通り**icon.svg**という名前で`src/app/`配下に配置することで解決しました。

以下、画像周りの配置例です。

```
src
  ├ public
  │   └ image
  │       └ ogp画像.png
  └ app
      ├ favicon.ico
      └ icon.svg
```

## index or noindex
これもSEO対策関連です。このサービスでは一般公開と限定公開の2種類存在するため、限定公開の場合はインデックス登録されないようにしました。鉢にはどちらのアンケートかを管理するフラグを持っているので、それで以て制御しています。

以下のようなイメージです。

```react
export async function generateMetadata() {
  const pot = await fetch('鉢データ取得');

  return {
    title: 'BloomSurvey',
    description: 'アンケートサービスです。',
    robots: {
        // index: falseの場合にnoindexになる
        index: !pot.linkOnly
      }
  };
}
```

## Safari検証
検証版をリリースしていたので、先日iphoneユーザの同期に触ってもらったところ鉢の画面で恐ろしいくらいにスタイルがずれていました（画像はありませんが、鉢が水滴に重なっていて何票入っているかわからなくなっていました）。
調べたところ`absolute`周りで動作に違いが出ることがあるようです。対応としては`className`に`bottom-0`を付けるだけの簡単なもので済みました。本番リリースはまだで、検証版だったのでよかったですがヒヤッとしました。やってないのは私くらいかもしれませんが`Chrome`で開発している人は`Safari`も確認するようにしましょう。

<details><summary>余談</summary>

（調査不足かもしれませんが）過去に`Windows版Safari`なるものがあったようですが、セキュリティホールの問題があるため使用NGだそうです。そのため`Windows`と`Android`ユーザの私は`Safari`の検証ができませんでした。最終的には直るであろうコードを書いてデプロイして、同期に見てもらいました。効率悪いですし、時間取ってしまって悪い気がしてしまいますし…良い方法があればご教授いただきたいです。
</details>

## ビルド&デプロイ作業
思ったよりも時間がかかってしまったので、次回触るときに困らないようにまとめておきます。

まず全体像です。以降で各番号について触れていきます。
![](https://storage.googleapis.com/zenn-user-upload/2d08c8bdca80-20250303.png)

#### ①ソースコードのPush
当たり前ですがコードを書いて動作確認を終えたらPushします。

#### ②トリガーによる検知とビルド
`Cloud Build`でトリガーを設定することで、例えば

* 〇〇ブランチにpushされたとき
* 〇〇ブランチにマージされたとき

のように任意のタイミングで自動でビルドすることができます。ビルドは`Dockerfile`に従って行われます。
このトリガー設定は`Cloud Build`のページを直接開いて設定することは勿論できますが、`Cloud Run`のヘッダー部分にある「継続的デプロイを編集します」から`Cloud Build`のページを開いて設定することもできます。
![](https://storage.googleapis.com/zenn-user-upload/21d0d3c272d9-20250303.png)

<details><summary>余談</summary>

個人開発では規模が大きくないこともありテストコードを書いたことがなく、手動でゴリゴリ動かしています。個人開発で小規模とはいえテストコードを書くのが一般的なのでしょうか…？
</details>

#### ③コンテナイメージのPush
ビルド結果に問題が無ければ`Artifact Registry`へコンテナイメージがPushされます。ビルド成功時に自動でPushされるので別途作業する必要はありません。ただし、トリガー設定時にイメージの場所と名前を設定する必要があります。

#### ④デプロイ
`Cloud Run`で作成したイメージを使ってデプロイをします。`Cloud Run`ヘッダー部の「新しいリビジョンの編集とデプロイ」からビルドしたコンテナイメージを選択してデプロイします。
![](https://storage.googleapis.com/zenn-user-upload/96e936e080ff-20250303.png)

### うまくいかない場合
サービスがうまく立ち上がらなかったりAPIが実行できなかったりしました。原因と解決方法を記載します。

#### サービスが立ち上がらない
**問題**
デプロイしたのにもかからわらず「これはplaceholderです。」のようなメッセージが表示され、サービスが立ち上がっていない状態でした。
**原因と対応**
リビジョンで設定していたコンテナイメージが誤っていました。`Artifact Registry`のどこにPushしているのかを`Cloud Build`で確認して、`Cloud Run`でそのコンテナイメージを設定できているか改めて確認する必要があります。


#### フロントエンドからAPIが叩けない
**問題**
サーバーサイドで実行されるAPIは問題なく動くのですが、ボタン押下等で実行されるAPIはエラーになりました。パスの一部が`undefined`になっていることを確認しました。
**原因と対応**
通常、フロントエンドでは`NEXT_PUBLIC_〇〇`という名前にすることで環境変数として有効になります。ただし、これが**ビルド時**に必要になることに注意が必要です。

つまり`Cloud Run`では例のように`Dockerflie`で宣言する必要があるということです。バックエンドで使用される環境変数はリビジョンで設定されているままで問題ありません。

```dockerfile
# APIのURL
ENV NEXT_PUBLIC_API_URL=APIのURLを設定
```

# マネタイズ
広告を埋め込みたいですが、ある程度のアクセス数が無いと審査が通らないため今のところ取り込めていないです。サブスクに憧れはありますが、機能がシンプルで最小限のため課金していただいても提供するものがないのが現状です。ダッシュボード画面で「工事中」としていますが、正直なところ工事は始まっていません。

当面は`Google AdSense`の審査に通ることを目標に今後も作業を続けてみます。

# コスト
参考になるかわかりませんが、このサービスが止まらない限りは更新し続けるようにします。

|月|費用|
|---|---|
|2月（開発のみ）|21円|
|3月（3月13日時点、開発のみ）|25円|

# 振り返りと今後
ひとまず開発の目的としていたUIコンポーネントを使用せず開発・リリースができてよかったです。苦手なりにデザインに力を入れてみて、全体の雰囲気を統一できました。苗や花も`Tailwind CSS`で作ることができて感動しました。

ただ、やはり開発するうえでデザイン面で何か勉強や改善が必要に感じました。イメージしたものを実装してみて、そこからちょこちょこ修正する方針で作業していました。ただ、そのイメージもいけてないことが多く練り直しが非常に多かったです。`Figma`等を使って先にデザインを固めるとだいぶ楽なんだろうなーと思いました。
次の開発に着手する前に`Figma`に挑戦してみます。

工事中の機能がいくつかあるので、このサービスの改修もやっていきます。
例えばサブスクです。触ってみたい領域ではあったので、提供する機能が決まり次第挑戦したいです。
鉢や苗の編集もできないので追加実装します（これは早々に対応するようにします）。

その他の機能も実装してより良いサービスにしたいので、触っていただいて「こんな機能があったら」、「こんなデザインはどうだろう」など改善点やアイディアをいただけると非常に嬉しいです。

https://bloomsurvey.com/

どこか一部でも参考になれば幸いです。
最後までありがとうございました。

# その他参考
記事の本文中で紹介していない参考記事等について紹介させてください。

こちらの記事を参考に読点の数を意識してみました。

https://zenn.dev/tmknom/articles/readable-github-cicd-book

リリース前チェックに使用させていただきました。

https://zenn.dev/catnose99/articles/547cbf57e5ad28