🍯

フロントエンドエンジニアがT3Stackを使ったら初めて個人開発を作り切りました!

2024/10/15に公開

はじめに

初めて個人開発をデプロイすることができたので、どのように作っていったか記載していきたいと思います!!

https://zenn.dev/kiwichan101kg/articles/279cc65988a39b

作ったアプリ

Everyday Into Giftsという名前のWebアプリです。
https://everyday-into-gifts-t3.vercel.app/

リポジトリはこちら
https://github.com/yumemiru-masomi/everyday-into-gifts-t3

概要

日々のストレスを文字で書いてストレスを発散しましょう❤️‍🔥
1文字1円として換算し、合計の金額が表示されます!
溜まったら、その分のお金を自分へのご褒美の金額として何か買っちゃいましょう🥰

実際のアプリはこちらです。

機能説明

Home画面はタイトルと概要、Sign inボタンを設置しています。

Sign inボタンを押下すると、Googleの認証が出てきます。

Sign inすると、Home画面が再度表示されます。

inputに吐き出したい内容を入力し、

送信ボタンを押します。

そうすると、メッセージが反映され、金額表示に入力した文字数分の数字が増えます。

ご褒美に使っちゃおうボタンを押下すると、モーダルが表示されます。

使っちゃうボタンを押下すると、金額が0になります。

かなりシンプルな作りです。必要最低限の実装を行いましたので、随時アップデートしていく予定です。

作り方

今回のアプリを作る際に行った主な流れです。

  1. 何を作るか考える
  2. どのように作るか整理する
  3. アプリを作る
  4. 個人開発を公開する前に気をつけることを確認する

一つずつ説明していきます。

何を作るか考える

はじめは、踊るさんま御殿の「言われて嬉しかった言葉をメモした紙に金包んで貯金し、その貯金で日頃の感謝のプレゼント渡す」という内容見てそのアプリ版を作ろうと思いました。

https://www.youtube.com/watch?v=H-xzoIOUFLs

しかし、カップルで共有するなら通知機能やシェア機能が欲しいと思い、モバイルで作成しようと考えました。

そこで、Webアプリの勉強をしたかった私は、シェアする必要のない、日々のストレスを吐き出す場所として今回のアプリを作りました。

カップルでシェアするアプリのモバイル版は今後作成予定です。

どのように作るか整理する

今回はT3Stackで早く一つ完成させたかったので、T3Stackがどのような仕組みなのかと、どのようなアプリを作るのかをはじめに整理しました。

FigJamを使用して頭の中を整理しました。無料で誰でも使えるのでぜひ使ってみてください。

こちらはT3Stackがどのような仕組みで成り立っているのかを理解するために書きました。

こちらはアプリの機能を整理したものです。機能をたくさん入れているので、今回は最小限のHome画面のみの実装を行いました。

私は、画面を設計してからDB設計し、APIを考えました。ここの理解があまりないのでお友達に手伝ってもらいました。0から考えるのは結構難しいので...

アプリを作る

ここからは実際に、T3Stackを使ってアプリを作成します。

アプリの雛形作成

下記コマンドを実装して、選択するとアプリの雛形が完成

npm create t3-app@latest

詳しくはこの記事を見ながら作ってください!
https://zenn.dev/kiwichan101kg/articles/279cc65988a39b

上記の記事でnpm run db:studioを実行し、画面が表示するところまで行ってください。
私が一番この中で苦労したのはDiscordの2段階認証です。

機能実装

機能実装の順番は

  1. フロントエンドでの入力と表示の実装
  2. prismaでモデルを作成
  3. tRPCでAPIエンドポイントの作成
  4. フロントエンドで組み込み
  5. npm run dev

で行っていきました。

本当は丁寧に説明したいのですが、こちら実装に関しては実際にコードを触りながら仕組みを理解して行った方が理解できるので、ざっくり実装順番の説明をさせていただきます。

フロントエンドでの入力と表示の実装

はじめにHomeのページにフロントを実装していきます。

import Link from "next/link";
import { LatestPost } from "~/components/LatestPost";
import { getServerAuthSession } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";

export default async function Home() {
  const session = await getServerAuthSession();

  void api.post.getLatest.prefetch();

  return (
    <HydrateClient>
      <main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
        <div className="container flex flex-col items-center justify-center gap-12 px-4">
          <h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
            Everyday <span className="text-[hsl(280,100%,70%)]">into</span>{" "}
            Gifts
          </h1>
          <p className="text-center">
            日々のストレスを文字で書いてストレスを発散しましょう❤️‍🔥
            <br />
            1文字1円として換算し、合計の金額が表示されます!
            <br />
            溜まったら、その分のお金を自分へのご褒美の金額として何か買っちゃいましょう🥰
          </p>
          {session?.user && <LatestPost />}
          {session?.user && (
            <div className="flex w-full flex-row items-center justify-end gap-4">
              <p className="text-2xl text-white">
                {session && <span>Logged in as {session.user?.name}</span>}
              </p>
              <Link
                href={session ? "/api/auth/signout" : "/api/auth/signin"}
                className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
              >
                {session ? "Sign out" : "Sign in"}
              </Link>
            </div>
          )}
          {!session?.user && (
            <div className="flex w-full flex-row items-center justify-center gap-4">
              <p className="text-2xl text-white">
                {session && <span>Logged in as {session.user?.name}</span>}
              </p>
              <Link
                href={session ? "/api/auth/signout" : "/api/auth/signin"}
                className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
              >
                {session ? "Sign out" : "Sign in"}
              </Link>
            </div>
          )}
        </div>
      </main>
    </HydrateClient>
  );
}

こちらの実装はほぼChatGPT4oに入れて生成してもらったものです。
デザインは最初にあったデザインを少し変えただけです。今回はロジックの部分の勉強をしたかったためです。

Googleで認証したい方は、こちらをみて実装してください!
https://zenn.dev/yumemi9808/articles/45dd37533aae40

prismaでモデルを作成

prisma/schema.prismaに元々定義してあるものに少しだけ追加して作りました。

model Post {
    id        Int      @id @default(autoincrement())
    message      String
    status      Boolean  @default(false)  // true = ご褒美に使った, false = 使っていない
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    createdBy   User   @relation(fields: [createdById], references: [id])
    createdById String

    @@index([message])
}

tRPCでAPIエンドポイントの作成

src/server/api/routers/post.tsというファイルに処理を追加して作成しました。
src/server/api/routers/stress.tsに命名を変え、ストレスメッセージに関するAPIの処理はここに入れます。後に増える可能性のある金額に関するAPIを増やすときはまた別にファイルを作成して作っていきます。

import { z } from "zod";

import {
  createTRPCRouter,
  protectedProcedure,
  publicProcedure,
} from "~/server/api/trpc";

type Post = {
  message: string;
};

export const stressRouter = createTRPCRouter({
  hello: publicProcedure
    .input(z.object({ text: z.string() }))
    .query(({ input }) => {
      return {
        greeting: `Hello ${input.text}`,
      };
    }),

  create: protectedProcedure
    .input(z.object({ message: z.string().min(1) }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.post.create({
        data: {
          message: input.message,
          createdBy: { connect: { id: ctx.session.user.id } },
        },
      });
    }),

  getLatest: protectedProcedure.query(async ({ ctx }) => {
    const post = await ctx.db.post.findFirst({
      orderBy: { createdAt: "desc" },
      where: { createdBy: { id: ctx.session.user.id } },
    });

    return post ?? null;
  }),

  getTotalMessageLength: protectedProcedure.query(async ({ ctx }) => {
    const userId = ctx.session.user.id;

    // 全てのメッセージを取得
    const messages: Post[] = await ctx.db.post.findMany({
      where: { createdById: userId, status: false },
      select: { message: true }, // messageフィールドのみを取得
    });

    // メッセージの文字数を合計
    const totalLength = messages.reduce(
      (acc, post) => acc + post.message.length,
      0,
    );

    return { totalLength };
  }),

  updateStatus: protectedProcedure.mutation(async ({ ctx }) => {
    const userId = ctx.session.user.id;

    // ステータスを更新
    const updatedPosts = await ctx.db.post.updateMany({
      where: {
        createdById: userId, // 現在のユーザーが作成した投稿
        status: false, // まだ「使っていない」投稿のみ
      },
      data: {
        status: true, // status を true に更新(リセット)
      },
    });
    return updatedPosts;
  }),
});

フロントエンドで組み込み

src/components/LatestPost.tsxでAPIで叩いた値を表示
const { data, refetch } = api.post.getTotalMessageLength.useQuery();とかで受け取ってます。

"use client";

import { useState } from "react";

import { api } from "~/trpc/react";

export function LatestPost() {
  // totalLengthを取得
  const { data, refetch } = api.post.getTotalMessageLength.useQuery();
  // モーダル
  const [isModalOpen, setIsModalOpen] = useState(false);
  const openModal = () => setIsModalOpen(true);
  const closeModal = () => setIsModalOpen(false);
  // 最新の投稿を取得
  const [latestPost] = api.post.getLatest.useSuspenseQuery();
  const utils = api.useUtils();
  const [message, setMessage] = useState("");
  const createPost = api.post.create.useMutation({
    onSuccess: async () => {
      await utils.post.invalidate();
      await refetch();
      setMessage("");
    },
  });
  const resetAmount = api.post.updateStatus.useMutation({
    onSuccess: async () => {
      await refetch();
    },
  });

  return (
    <div className="flex flex-col items-center">
      <div className="justify-cente container flex flex-col items-center">
        <p className="text-7xl">
          {data?.totalLength?.toLocaleString() ?? "🥺"}
          <span className="text-3xl"></span>
        </p>
        <button
          className="mb-7 mt-4 rounded-full bg-red-500 px-6 py-2 text-white"
          onClick={openModal}
        >
          ご褒美に使っちゃおう!
        </button>
      </div>
      {isModalOpen && (
        <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
          <div className="rounded-lg bg-white p-8 text-center">
            <p className="mb-4 text-lg text-black">
              本当に使ってもいいですか?
            </p>
            <p className="pb-4 text-5xl text-black">
              {data?.totalLength?.toLocaleString() ?? 0}
              <span className="text-3xl text-black"></span>
            </p>
            <div className="flex justify-center gap-4">
              <button
                className="rounded-full bg-red-500 px-6 py-2 text-white"
                onClick={() => {
                  resetAmount.mutate();
                  setIsModalOpen(false);
                }}
              >
                使っちゃう!
              </button>
              <button
                className="rounded-full bg-gray-500 px-6 py-2 text-white"
                onClick={closeModal}
              >
                キャンセル
              </button>
            </div>
          </div>
        </div>
      )}
      {latestPost ? (
        <p className="mx-auto w-[500px] whitespace-normal pb-7 text-center text-3xl">
          {latestPost.message}
        </p>
      ) : (
        <p className="pb-7 text-center text-3xl">
          ここにストレスを吐き出そう!
        </p>
      )}
      <div className="w-full max-w-xs">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            createPost.mutate({ message });
          }}
          className="flex flex-col gap-2"
        >
          <input
            type="text"
            placeholder="Stress Message"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            className="w-full rounded-full px-4 py-2 text-black"
          />
          <button
            type="submit"
            className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
            disabled={createPost.isPending}
          >
            {createPost.isPending ? "Submitting..." : "Submit"}
          </button>
        </form>
      </div>
    </div>
  );
}

これでnpm run devして、立ち上がったら成功です🎉

Supabase

prisma/schema.prismaに書いてある、sqlightをpostgresqlに修正。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
    provider = "postgresql"//ここを修正
    // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
    // Further reading:
    // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
    // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
    url      = env("DATABASE_URL")
}

Supabaseを設定します。
https://supabase.com/dashboard/project/ymyxlurymojcdfhjkaiq

接続するボタンを押下

URLをコピー

このように.envに書いて貼り付ける

DATABASE_URL="postgresql://username:password@your-database-host:5432/database-name"

postgresqlをインストール

brew install postgresql

psqlコマンドを実行して確認

psql postgresql://username:password@your-database-host:5432/database-name

できたか確認します。

DATABASE_URLの6543のポート番号を→5432に変更してから下記コマンドを実行

npx prisma migrate dev --name init

これやったらエラーが出てしまったので、

migration配下のファイルを消してから、もう一度上記のコマンドを実行しました。

npx prisma generate
npm run db:studio

立ち上がればOK

Vercelにデプロイ

Vercelにデプロイするときに、インポートに表示されなかったので、その方法も一緒に書いておきます。初めからインポートできる方は飛ばしてください。

検索に対象のリポジトリがでてっこなかったとき。

Add New...ボタンを押下します。

ApplicationsのConfigureボタンを押下します。

Select repositoriesを押下し、自分がデプロイしたいリポジトリを選択します。

そうするとインポートに表示されます。インポートしてください。

環境変数を設定してください。種類は、DiscordとGoogleとNextAuthとDataBase等の設定です。.envに記載しているものを記載してください

production、preview、developmentで設定が可能です。

無事デプロイできました🎉

おまけ
最初のVercelの画面から環境変数の画面に行くときにいつも迷子になるのは私だけかも知れませんが、ここに環境変数の画面までの行き方を載せておきます。
自分のアイコンをクリック



デプロイしたアプリを確認しよう

早速デプロイしたアプリを触ってみよう!
と思ってGoogleで認証しようとしたらこの画面.......怖

Googleのコールバックの設定がローカルだったことが原因でした。
https://console.cloud.google.com/apis/credentials?project=yumemi-437407

承認済みのリダイレクト URIにhttps://everyday-into-gifts-t3.vercel.app/api/auth/callback/googleを追加

これで問題なく動作するようになりました🎉

個人開発を公開する前に気をつけること

NextAuthを実装している場合は、プラポリと利用規約を書いた方がいい

私はこのようなツリー構造にしてページを作成しました。

src
└── app
    └── legal
        ├── privacy
        │   └── page.tsx  # プライバシーポリシーページ
        ├── terms
        │   └── page.tsx  # 利用規約ページ
        └── layout.tsx    # legalディレクトリのレイアウトコンポーネント

metadataの設定(URLで貼った時に表示される文言)

src/app/layout.tsxに書いてあるので、書き換えます。

import "~/styles/globals.css";

import { GeistSans } from "geist/font/sans";
import { type Metadata } from "next";

import { TRPCReactProvider } from "~/trpc/react";
import { Footer } from "~/components/Footer";

//ここ!!
export const metadata: Metadata = {
  title: "Everyday Into Gifts",
  description: "日々のストレスを文字で書いてストレスを発散しましょう❤️‍🔥",
  icons: [{ rel: "icon", url: "/gifts.png" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" className={`${GeistSans.variable}`}>
      <body>
        <TRPCReactProvider>
          {children}
          <Footer />
        </TRPCReactProvider>
      </body>
    </html>
  );
}

favicon.icoの設定

ブラウザのタブやブックマークに、自分の好きなアイコンが表示されるようになります。デフォルトのデザインのままだと、なんのアプリか分からなくなってしまいます。

まとめ

とっても長くなってしまいましたが、読んでくださってありがとうございます!
個人開発の流れを掴んでくださると幸いです!

Discussion