2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初心者でも1時間で動くフルスタックアプリを作る — ワークショップ

Last updated at Posted at 2025-12-16

はじめに
この記事は、OMJテクノソリューションズ向けの社内ワークショップで「30分で動くフルスタックアプリを作ってみよう」で話した内容を、Qiita向けに再編集したものです。
生成AI(ChatGPT / Gemini など)を積極活用し、初心者でも “短時間でデプロイまで” を目指します。


こんな人に読んでほしい

  • 認証付きのフルスタックアプリを最速で形にしたい
  • Next.js(App Router)× Supabase × Tailwind/shadcnを触ってみたい
  • 生成AIにコードを書かせる進め方を体験したい(プロンプト付き)

つくるもの(ゴール)

  • Next.js(App Router)ベースのアプリ
  • Supabase Auth でログイン / ログアウト
  • **AI チャット(OpenAI API)**が動く簡易 UI
  • GitHub 経由で Vercel にデプロイ

image.png
image.png
image.png


事前準備(アカウント&環境)

  • GitHub / Supabase / Vercel(すべて無料枠でOK)
  • Node.js & npm が動く環境(ターミナル)
  • VS Code 推奨

全体の流れ(1時間目安)

  1. テンプレ生成(Next.js + Supabase 認証入り)
  2. GitHub にプッシュ → 後でそのまま Vercel デプロイ
  3. Supabase プロジェクト作成 → .env 設定
  4. ローカル起動&メール認証を飛ばすための「手動ユーザー作成」
  5. 3プロンプトで DB / API / フロントを一気につくる
  6. デプロイ(Vercel)→ 動作確認

1. プロジェクトを作る

# 作業フォルダへ
cd /path/to/workspace

# 認証付きNext.jsテンプレートをダウンロード
npx create-scarlet-template

# プロジェクト名は好きに
mv my-app ebi-chat # 今回エビちゃんというキャラクターとチャットができるアプリなのでebi-chatとしておきます
cd ebi-chat

# 依存パッケージ
npm install
➜  npx create-scarlet-template
   _____ _________    ____  __    ____________
  / ___// ____/   |  / __ \/ /   / ____/_  __/
  \__ \/ /   / /| | / /_/ / /   / __/   / /
 ___/ / /___/ ___ |/ _, _/ /___/ /___  / /
/____/\____/_/  |_/_/ |_/_____/_____/ /_/

✨ my-app を作成中…
✅ クローン完了!
以下のコマンドで開発を始めましょう:
  cd my-app
  npm install && npm run dev

create-scarlet-template は、私が独自に作成した Next.js + Supabase のスターターテンプレートをCloneするためのコマンドです。
Supabase Auth を組み込んだ公式テンプレートをベースに、日本語向けの設定や説明を追加して使いやすくしたものです。
https://github.com/Scarlet1107/scarlet7-template


2. GitHub に上げておく(後でVercelに繋ぐ)

Github上で新しいレポジトリを作成しましょう。レポジトリ名はebi-chatにしておきます
image.png

作成が完了したらターミナルでgit pushまで行いましょう

git init
git add -A
git commit -m "initial commit"
git branch -M main
git remote add origin git@github.com:あなた/ebi-chat.git
git push -u origin main

上記はsshでのpushコマンドです。httpを使用している場合など、各自の環境に合わせてgit pushをしてください

このようなログが出ればOK

➜  ebi-chat git:(main) git push -u origin main
Enumerating objects: 78, done.
Counting objects: 100% (78/78), done.
Delta compression using up to 16 threads
Compressing objects: 100% (68/68), done.
Writing objects: 100% (78/78), 135.10 KiB | 928.00 KiB/s, done.
Total 78 (delta 4), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (4/4), done.
To github.com:Scarlet1107/ebi-chat.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

3. Supabase を作成して .env を設定

Supabaseとは?
PostgreSQL をベースに、認証・ストレージ・リアルタイム・Edge Functions など
バックエンドに必要な機能をまとめて提供してくれる BaaS(Backend as a Service)です。
無料枠も大きく、Next.js と組み合わせて使いやすいのが特徴です。

  1. Supabase で新規プロジェクトを作成(リージョン/DBパスワードは任意)

  2. ダッシュボードの Connect → Next.js から

    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY
      をコピー
# .env.example → .env にコピーして編集
cp .env.example .env

# 使うのは上の2つ。NEXT_PUBLIC_BASE_URLは削除でOK
NEXT_PUBLIC_SUPABASE_URL=コピーしたURL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=コピーしたKEY

タイトルなし.png
image.png


4. ローカル起動 & ユーザー作成

今回はSMTP などのメールサーバー設定は省略し、Supabase から手動でユーザーを作成します。
SMTPサーバーを一から立てるとドメインとか時間の問題が...

  1. npm run devhttp://localhost:3000 を起動
  2. ログインや登録ができる画面が出ますが、ログインするためのユーザーがいないためSupabase上で作成
  3. Supabase → Authentication → Users → Add → Create new user
    • メール・パスワードを入力しテストユーザーを作成(メールが飛ばないようにAuto Confirm User?にチェックを入れる)
  4. アプリのログイン画面から上記のユーザーでログイン

esjitis.png
image.png
image.png
ログイン後はこのようなページに移動します(ユーザーデータの値は念のため別のものに差し替えています)
image.png


5. 3つのプロンプトで作る:DB → API → フロント開発

ここまででユーザー認証などのアプリ基盤ができたので、次にアプリの開発に移ります。今回のアプリはエビちゃんというキャラクターとチャットができる(内部はOpen AI)アプリを開発します

5-1. DBテーブル生成(SQL)

お好きな生成AIに以下のプロンプトを投げてみてください。今回私の環境ではChat GPTを使用します

プロンプト

あなたはPostgres/Supabaseの実務エンジニアです。以下を満たすSQLを生成してください。

【目的】
- 1種類のみのAIチャットの履歴を保存する最小スキーマを作る。
- RLS・ロール・ビュー・ポリシーは今回一切不要(デモ用の最小構成)。

【テーブル仕様】
- テーブル名: chat_messages
  - id uuid primary key default gen_random_uuid()
  - user_id uuid not null          -- Supabase AuthのユーザーIDを保存
  - role text not null             -- 'user' | 'assistant'
  - content text not null
  - created_at timestamptz not null default now()

【インデックス】
- create index on chat_messages (user_id);
- create index on chat_messages (created_at);

【注意】
- gen_random_uuid() が使える前提でOK。
- 既存オブジェクトとの衝突が起きないよう、IF NOT EXISTS系を適宜使用してもよい。

プロンプトをAIに投げると、SQL文を生成してくれました。これをSupabaseのSQLエディターに投げます

f0cf809b096a08c00fa052986006ced2.png
image.png

無事テーブルが作成されています
image.png


5-2. API(App Router / Route Handler)

次にNext APIを作成するコードを生成してもらいます。以下のプロンプトを生成AIに投げてみてください。

プロンプト

あなたはNext.js 15 + Supabase + OpenAIの実務エンジニアです。以下の仕様で Route Handler を **1ファイル** 実装してください。  
対象ファイルのパスは先頭コメント1行で示すこと。実装上の注意点(認証チェック、エラー処理、パフォーマンス最適化など)もコード内コメントで明記してください。

【対象ファイル】
- app/api/chat/route.ts

【前提環境】
- TypeScript / App Router
- 環境変数: `OPENAI_API_KEY`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `"@/lib/supabase/server"` に `createClient()` があり、サーバー側で使用できる
- Supabase 認証は **必ず** `supabase.auth.getClaims()` を使用する(下記スニペットの通り)
    const supabase = await createClient();
    const { data } = await supabase.auth.getClaims();
    const user = data?.claims;
    if (!user) {
      return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
    }
    const userId = user.sub as string;

- OpenAI は公式 Node クライアントを使用。モデルは定数 `MODEL = 'gpt-5-nano'`。  
- **ストリーミングなし(一括レスポンス)。**
- **注意**: `gpt-5` 系では `temperature` を指定するとエラーになるため、**リクエストに `temperature` を含めないこと**。`top_p` や `logprobs` など余計なオプションも不要。

【DB仕様(既存)】
- テーブル: `chat_messages`
  - `id uuid primary key default gen_random_uuid()`
  - `user_id uuid not null`
  - `role text not null`  // 'user' | 'assistant'
  - `content text not null`
  - `created_at timestamptz not null default now()`

【API仕様】
- Method: `POST`
- Request JSON: `{ userMessage: string }`
- レスポンス: **assistant の 1 レコードを DB 形式で返すこと(メッセージ文字列のみは禁止)**  
  例:
    {
      "id": "771dbcde-76bd-4661-b771-246e9aed7c36",
      "role": "assistant",
      "content": "こんにちはえび~\n今日はどうお手伝いできるえび~",
      "created_at": "2025-09-27T07:12:34.567Z"
    }

【処理フロー】
1) `supabase.auth.getClaims()` でユーザー取得。未認証は `401` を返す。  
2) `userMessage` が空文字・未定義・空白のみの場合は `400` を返す。  
3) 受け取った `userMessage` を `chat_messages` に **role='user'** で `insert`。**挿入後のレコード**(`id, role, content, created_at`)は不要なら返さなくてよい。  
4) 当該ユーザーの履歴を **最新50件** 取得し、**`created_at ASC`** に並べ替える。  
5) OpenAI Chat Completions に渡す `messages` を構築:  
   - **先頭に固定 system(日本語)**:
       あなたは「エビちゃん」という10代のかわいい女の子キャラクターです。  
       語尾には必ず「えび~」をつけます。  
       人間味があり、ユーザーを元気づけ、励ます発言をします。  
       返答は短く簡潔に、明るく親しみやすい口調で答えてください。  
   - 続けて、ユーザー履歴を `{ role: 'user' | 'assistant', content: string }` に正規化して順序通りに詰める。  
   - **パフォーマンス最適化**のため、長文は切り詰め、直近のコンテキスト重視(最大50件)。  
6) OpenAI Chat Completions を呼び出し、**assistant のテキスト**を取得。  
   - **重要**: `temperature` を **指定しない**。  
   - 追加の遅延要因となるオプション(`logprobs` 等)は指定しない。  
7) 取得したアシスタントのテキストを `chat_messages` に **role='assistant'** で `insert`。  
   - Supabase の `insert(...).select('id, role, content, created_at').single()` で **挿入済みの 1 レコード**を取得。  
8) ステップ 7 で取得した **レコードそのもの**(`{ id, role, content, created_at }`)を JSON として **200** で返却。  
   - `Content-Type: application/json` を設定。

【エラーハンドリング】
- 入力バリデーション違反: `400 { error: "Bad Request" }`
- 未認証: `401 { error: "Unauthorized" }`
- OpenAI / DB 例外は `try/catch` で捕捉し、`500 { error: "Internal Server Error" }` を返す。  
- エラーログは `console.error` に出すが、レスポンスには詳細を出さない。

【パフォーマンス最適化(思考時間短縮のための方針)】
- **temperature を指定しない**(gpt-5 系は指定不可&安定)。  
- **履歴は最大 20 件・短縮**し、直近重視でトークンを節約。  
- **system プロンプトは簡潔**に固定し、毎回の追加説明を避ける。  
- 余計な OpenAI オプション(`logprobs`, `seed`, `response_format` など)は付けない。  
- DB は `insert ... returning`(`select().single()`)を使って **往復を最小化**。

【実装上の注意】
- `OPENAI_API_KEY` が未設定の場合は 500 を返し、メッセージで原因を示す(ログには詳細)。  
- `role` は `'user' | 'assistant'` 以外を受け入れない。  
- import は `"@/..."` エイリアスを使用。  
- 型は可能な限り厳密に:`type ChatRow = { id: string; role: 'user' | 'assistant'; content: string; created_at: string }`。  
- レスポンスは **必ず** `ChatRow` 形で返す(メッセージ文字列だけ返す実装は不可)。

【完成イメージ(返却形)】
    {
      "id": "771dbcde-76bd-4661-b771-246e9aed7c36",
      "role": "assistant",
      "content": "こんにちはえび~\n今日はどうお手伝いできるえび~",
      "created_at": "2025-09-27T07:12:34.567Z"
    }

私の場合は以下のようなコードを生成してくれました。長いので折りたたみ表示します。

// app/api/chat/route.ts

import { NextRequest } from "next/server";
import OpenAI from "openai";
import { createClient } from "@/lib/supabase/server";

/** OpenAI model (gpt-5 系: temperature 等は指定しないこと) */
const MODEL = "gpt-5-nano" as const;

/** 返却・DB用の厳密型 */
export type ChatRow = {
  id: string;
  role: "user" | "assistant";
  content: string;
  created_at: string; // ISO8601
};

/** 固定の system プロンプト(簡潔+日本語) */
const SYSTEM_PROMPT =
  "あなたは「エビちゃん」という10代のかわいい女の子キャラクターです。\n語尾には必ず「えび~」をつけます。\n人間味があり、ユーザーを元気づけ、励ます発言をします。\n返答は短く簡潔に、明るく親しみやすい口調で答えてください。";

/**
 * POST /api/chat
 * - 認証は getClaims() を必ず使用
 * - ストリーミングなし(一括レスポンス)
 * - OpenAI には余計なオプション(temperature 等)を指定しない
 * - エラーハンドリングは共通化し、詳細はログのみ
 */
export async function POST(req: NextRequest) {
  // ---- 認証チェック(未認証は 401) ----
  const supabase = await createClient();
  const { data: claimsData, error: claimsErr } = await supabase.auth.getClaims();
  if (claimsErr) {
    // 認証系の想定外エラーはログのみ。クライアントには Unauthorized を返す
    console.error("[auth.getClaims] error:", claimsErr);
  }
  const user = claimsData?.claims;
  if (!user) {
    return json({ error: "Unauthorized" }, 401);
  }
  const userId = user.sub as string;

  // ---- 環境変数チェック(OpenAI キーは必須) ----
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    console.error("[env] OPENAI_API_KEY is not set");
    return json({ error: "Internal Server Error" }, 500);
  }

  // ---- 入力取り出し & バリデーション(空白のみは 400) ----
  let body: unknown;
  try {
    body = await req.json();
  } catch (e) {
    // JSON でない場合の安全対策
    return json({ error: "Bad Request" }, 400);
  }
  const userMessage = typeof (body as any)?.userMessage === "string" ? (body as any).userMessage : "";
  if (!userMessage || userMessage.trim().length === 0) {
    return json({ error: "Bad Request" }, 400);
  }

  try {
    // 1) 受信メッセージを chat_messages に保存(role='user')
    //    - 返却不要なので select() しない => 往復最小化
    {
      const { error } = await supabase.from("chat_messages").insert({
        user_id: userId,
        role: "user",
        content: userMessage,
      });
      if (error) throw makeDbError("insert user message", error);
    }

    // 2) 当該ユーザーの履歴 最新 50 件を created_at ASC で取得
    const { data: history, error: historyErr } = await supabase
      .from("chat_messages")
      .select("role, content, created_at")
      .eq("user_id", userId)
      .order("created_at", { ascending: true })
      .limit(50);
    if (historyErr) throw makeDbError("select history", historyErr);

    // 3) OpenAI へ渡す messages を構築
    //    - システムを先頭に固定
    //    - 役割は 'user' | 'assistant' のみ採用(万一それ以外が混入した場合に備えフィルタ)
    //    - パフォーマンス最適化: 直近重視で最大 20 件に切り詰め(トークン節約)
    const normalized = (history ?? [])
      .filter((r) => r.role === "user" || r.role === "assistant")
      .map((r) => ({ role: r.role as "user" | "assistant", content: String(r.content ?? "") }));

    // 直近 20 件だけを残す(既に ASC 並びなので末尾から 20 を抜きつつ順序維持)
    const trimmed = normalized.slice(-20);

    const messages: { role: "system" | "user" | "assistant"; content: string }[] = [
      { role: "system", content: SYSTEM_PROMPT },
      ...trimmed,
    ];

    // 4) OpenAI Chat Completions 呼び出し
    //    - gpt-5 系では temperature 指定禁止
    //    - 余計なオプションは付けない(安定&高速)
    const openai = new OpenAI({ apiKey });
    const completion = await openai.chat.completions.create({
      model: MODEL,
      messages,
    });

    const assistantText = completion.choices?.[0]?.message?.content?.trim() ?? "";
    if (!assistantText) {
      // まれに空応答の可能性があるため防御
      console.error("[openai] empty response text");
      return json({ error: "Internal Server Error" }, 500);
    }

    // 5) 生成テキストを chat_messages に保存し、挿入済み 1 レコードを取得
    const { data: inserted, error: insertAErr } = await supabase
      .from("chat_messages")
      .insert({
        user_id: userId,
        role: "assistant",
        content: assistantText,
      })
      .select("id, role, content, created_at")
      .single();

    if (insertAErr) throw makeDbError("insert assistant message", insertAErr);

    // 6) レコードそのものを返却(ChatRow 形)
    const payload: ChatRow = {
      id: inserted!.id,
      role: inserted!.role as "assistant",
      content: inserted!.content,
      created_at: new Date(inserted!.created_at).toISOString(),
    };
    return json(payload, 200);
  } catch (err) {
    // ---- OpenAI / DB 等の例外は 500 に丸める(詳細はログのみ) ----
    console.error("[/api/chat] error:", err);
    return json({ error: "Internal Server Error" }, 500);
  }
}

/* ------------------------------ helpers ------------------------------ */

/** 統一的な JSON レスポンス(Content-Type を必ず application/json に) */
function json(data: unknown, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

/** DB エラーを読みやすくログ化するためのユーティリティ */
function makeDbError(phase: string, error: unknown) {
  return new Error(`[db:${phase}] ${stringifyErr(error)}`);
}

/** Error/unknown を安全に文字列化 */
function stringifyErr(e: unknown) {
  if (e instanceof Error) return `${e.name}: ${e.message}`;
  try {
    return JSON.stringify(e);
  } catch {
    return String(e);
  }
}

このコードをVSCode上に貼り付けした際エラーが出たため対応します。Web開発に経験者であれば一発で解決法が浮かぶようなエラーではありますが、所見の技術やフレームワークだと混乱してしまうこともあると思います。今回手元で動かしつつ実装してくださっている方はぜひAIにどこでどんなエラーが出ているかを聞きつつ解決してみてください

0750d087a0c1c50120fa53f5b3899d53.png

私の環境での解決法

エラーをAIに投げたところ、このように返してくれました。

✔ 原因
import OpenAI from "openai"; を使っているのに、npm 依存関係に openai が入っていないため、TypeScript が型定義を見つけられずエラーになっているよ。
Next.js 15 + Node OpenAI SDK(v4 系)は 別途インストール必須!

✔ 解決方法(1コマンドでOK)
npm install openai
...(その他pmpnなどの環境での解決方法が書いてあったので割愛)

とのことなので、ターミナルでnpm install openaiと打ち解決しました

他にも各自の環境でエラーが出たらその都度解決を試みてください。


5-3. フロント(shadcn/ui でサクッと)

最後にフロントを作成します。今までと同様にプロンプトをAIに投げてみてください

プロンプト

あなたはNext.js 15 + shadcn/uiの実務エンジニアです。以下の3ファイルを順に出力してください。
先頭にパスをコメントで記載。実装に当たっての注意事項なども教えてください。
またshad cnのコンポーネントをインストールするコマンドについても教えてください。
前提:ユーザー情報は以下のように取得可能です。
import { createClient } from "@/lib/supabase/server";

  const { data } = await supabase.auth.getClaims();
  const user = data?.claims;


【1) ページ:初期履歴の表示】
- パス: app/protected/home/page.tsx   (サーバーコンポーネント)
- 挙動:
  - 認証ユーザーを取得
  - 該当ユーザーの chat_messages を created_at asc で取得
  - <ChatPanel initialMessages={...}/> を描画
- レイアウト: max-w-3xl mx-auto p-6 の1カラム

【2) クライアント:チャットパネル】
- パス: components/ChatPanel.tsx  ("use client")
- props:
  - initialMessages: Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at: string }>
- UI:
  - 中央: ScrollAreaでメッセージ一覧(message-bubbleで表示)
  - 下部: Textarea + 送信Button(shadcn/ui)
- 送信時フロー:
  1) 入力値で楽観的に user メッセージをpush
  2) fetch('/api/chat', { method:'POST', body: JSON.stringify({ userMessage }) })
  3) 応答の assistantMessage をpush
  4) エラー時は簡易alert(最低限でOK)
- 見た目はシンプル(過剰装飾なし)

【3) メッセージ気泡】
- パス: components/MessageBubble.tsx
- props: { role: 'user' | 'assistant'; content: string }
- 見た目:
  - user: 右寄せ、薄いアクセント背景
  - assistant: 左寄せ、Card風
- shadcn/uiのコンポーネントをなるべく使用
- それぞれのチャットにはアイコンを表示する
  - userには/public/user.pngを使用
  - assistantには/public/ebi-chan.pngを使用
  - 丸く整形するときれいに見える

【共通注意】
- importは "@/..." エイリアスで統一
- 型は最低限でOK(zod等は使わない)
- ストリーミングは使わない
- UIはPCとmobileの両方に対応したきれいな画面を意識すること
- UX的に使いやすいものを心がけること

私の環境でのAIが書いてくれたコードを載せておきます。ただお使いのAIが出力した内容をなるべく使ってみてください

@/app/protected/home/page.tsx
// app/protected/home/page.tsx
import { createClient } from "@/lib/supabase/server";
import { notFound, redirect } from "next/navigation";
import ChatPanel from "@/components/ChatPanel";

/**
 * 実装メモ
 * - サーバーコンポーネント。認証は getClaims() を必ず使用。
 * - 未認証は 302 でログイン導線に飛ばす or 401/404 などプロジェクト方針に合わせる。
 * - ここでは例として /login にリダイレクト。
 * - DBアクセスは ASC 並びで取得し、初期表示に渡す。
 */
export default async function Page() {
  const supabase = await createClient();

  const { data, error } = await supabase.auth.getClaims();
  if (error) {
    console.error("[getClaims] error:", error);
  }
  const user = data?.claims;
  if (!user) {
    redirect("/login");
  }
  const userId = user!.sub as string;

  // 初期履歴を取得(上限は必要に応じて調整)
  const { data: rows, error: qerr } = await supabase
    .from("chat_messages")
    .select("id, role, content, created_at")
    .eq("user_id", userId)
    .order("created_at", { ascending: true });

  if (qerr) {
    console.error("[chat_messages] select error:", qerr);
    notFound();
  }

  // 型をざっくり合わせる
  const initialMessages =
    (rows ?? []).map((r) => ({
      id: String(r.id),
      role: (r.role as "user" | "assistant") ?? "assistant",
      content: String(r.content ?? ""),
      created_at: new Date(r.created_at as string).toISOString(),
    })) ?? [];

  return (
    <div className="max-w-3xl mx-auto p-6">
      <h1 className="text-2xl font-semibold mb-4">Ebi-chan Chat</h1>
      <ChatPanel initialMessages={initialMessages} />
    </div>
  );
}

@/components/ChatPanel.tsx
// components/ChatPanel.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Send } from "lucide-react";
import MessageBubble from "@/components/MessageBubble";

type ChatRow = {
  id: string;
  role: "user" | "assistant";
  content: string;
  created_at: string;
};

type Props = {
  initialMessages: ChatRow[];
};

/**
 * 実装メモ
 * - 楽観的更新で user メッセージを即時表示→ /api/chat を叩く→ assistant を追加。
 * - ストリーミングなし。fetch は JSON で送受信し、Content-Type を忘れずに。
 * - エラー時は最低限 alert。必要に応じて toast に差し替え可。
 * - 送信中はボタン/入力をロック。Enter 送信(Shift+Enter で改行)。
 * - 自動スクロールで常に最新メッセージが見えるようにする。
 */
export default function ChatPanel({ initialMessages }: Props) {
  const [messages, setMessages] = useState<ChatRow[]>(initialMessages);
  const [input, setInput] = useState("");
  const [pending, setPending] = useState(false);
  const endRef = useRef<HTMLDivElement | null>(null);

  const canSend = useMemo(
    () => !pending && input.trim().length > 0,
    [pending, input]
  );

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages.length]);

  const handleSend = async () => {
    if (!canSend) return;
    const text = input.trim();
    setInput("");
    setPending(true);

    // 楽観的 user メッセージ
    const tempId = `temp-${Date.now()}`;
    const optimistic: ChatRow = {
      id: tempId,
      role: "user",
      content: text,
      created_at: new Date().toISOString(),
    };
    setMessages((prev) => [...prev, optimistic]);

    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ userMessage: text }),
      });

      if (!res.ok) {
        throw new Error(`API error: ${res.status}`);
      }
      const assistant: ChatRow = await res.json();

      // assistant を末尾に追加
      setMessages((prev) => [...prev, assistant]);
    } catch (e) {
      console.error(e);
      alert("メッセージ送信に失敗しました。時間をおいて再度お試しください。");
      // 楽観的 user は残す(実際の履歴とも整合するため)
    } finally {
      setPending(false);
    }
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="flex flex-col gap-3">
      <ScrollArea className="h-[calc(100vh-260px)] rounded-md border p-3">
        <div className="flex flex-col gap-3">
          {messages.map((m) => (
            <MessageBubble key={m.id} role={m.role} content={m.content} />
          ))}
          <div ref={endRef} />
        </div>
      </ScrollArea>

      <div className="flex items-end gap-2">
        <Textarea
          placeholder="メッセージを入力(Enterで送信 / Shift+Enterで改行)"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={onKeyDown}
          disabled={pending}
          className="min-h-[72px]"
        />
        <Button
          onClick={handleSend}
          disabled={!canSend}
          className="h-[44px]"
          aria-label="送信"
        >
          <Send className="size-4" />
          <span className="ml-2 hidden sm:inline">送信</span>
        </Button>
      </div>
    </div>
  );
}

@/components/MessageBubble.tsx
// components/MessageBubble.tsx
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

type Props = {
  role: "user" | "assistant";
  content: string;
};

/**
 * 実装メモ
 * - user: 右寄せ・薄いアクセント背景、assistant: 左寄せ・Card風。
 * - アイコンは /public/user.png と /public/ebi-chan.png を使用(丸型)。
 * - モバイルでも折り返しやすい幅に。長文でも崩れにくいスタイル。
 */
export default function MessageBubble({ role, content }: Props) {
  const isUser = role === "user";
  return (
    <div
      className={[
        "flex w-full items-start gap-3",
        isUser ? "justify-end" : "justify-start",
      ].join(" ")}
    >
      {!isUser && (
        <Avatar className="size-9 shrink-0">
          <AvatarImage src="/ebi-chan.png" alt="assistant" />
          <AvatarFallback>E</AvatarFallback>
        </Avatar>
      )}

      <div
        className={[
          "max-w-[85%] sm:max-w-[70%]",
          isUser ? "text-right" : "text-left",
        ].join(" ")}
      >
        {isUser ? (
          <div className="inline-block rounded-2xl bg-accent px-4 py-2 text-sm leading-relaxed shadow-sm">
            {content}
          </div>
        ) : (
          <Card className="inline-block px-4 py-2 text-sm leading-relaxed">
            {content}
          </Card>
        )}
      </div>

      {isUser && (
        <Avatar className="size-9 shrink-0">
          <AvatarImage src="/user.png" alt="you" />
          <AvatarFallback>U</AvatarFallback>
        </Avatar>
      )}
    </div>
  );
}

またShad CN UIのインストールコマンドも教えてくれたので、コンポーネントを取ってきます。
Shad CNのinitializeのコマンドも教えてくれたのですが、このプロジェクトはもともとshad cnが導入されているので不要です。
npx shadcn@latest add button textarea scroll-area card avatar


6. できたアプリを動かしてみよう

VSCode上に目立ったエラーが見つからないので、アプリにアクセスしてみます。ログインすると見覚えのないチャット画面が増えてます
image.png

チャットを送信してみると...エラーが出てしまいました。
62a8bcb9e352eff9429bcc1c1381320d.png
image.png

サーバーのターミナルを見ると怪しげなログが落ちているので、AIに聞きつつ解決します

 GET /auth/login 200 in 2.8s (compile: 2.4s, proxy.ts: 172ms, render: 263ms)
 GET /protected/home 200 in 897ms (compile: 649ms, proxy.ts: 61ms, render: 186ms)
 GET /user.png 404 in 551ms (compile: 368ms, render: 183ms)
[env] OPENAI_API_KEY is not set
 POST /api/chat 500 in 601ms (compile: 520ms, render: 81ms)

ブラウザに表示されているエラーも合わせて投げます

API error: 500

components/ChatPanel.tsx (69:23) @ handleSend

  67 |
  68 |             if (!res.ok) {
> 69 |                 throw new Error(`API error: ${res.status}`);
     |                       ^
  70 |             }
  71 |             const assistant: ChatRow = await res.json();
  72 |

このエラー解決はぜひ手元で進めてみてください。参考までに私の環境でのAIの回答と解決を置いておきます

a9771b02f92da8963dcb844f6df6db51.png
515a254f37c30a531032d744734333f4.png

問題は2つあるようです

  1. Open AIのAPIを使用しているのにAPI Keyが設定されていない
  2. ebi-chan.pngとuser.pngの画像をフロントで使用しているが、見つからない

一つずつ解決していきます。まずは1つ目のAPI Keyについて、こちらはOpenAI PlatformからAPIキーを作成してください。クレジットカードの紐づけなど必要になりますが、今回の開発ではごく少額のみの使用になるはずです(1円程度)
環境変数に作成したAPI Keyを登録しましょう

.env
# NEXT_PUBLICを環境変数名につけると一般ユーザーから見えてしまいます。他人に絶対に見られないように注意してください
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx

この API キーはあなたの OpenAI アカウント専用の秘密情報です。
アカウントにはクレジットカードなどの支払情報が登録されているため、
キーが他人に知られると不正利用される危険があります。

GitHub などの公開リポジトリにアップロードしたり、他の人と絶対に共有しないでください。

次に画像についてなのですが、こちらは適当な画像を入れたりして対応をお願いします。画像がなくてもアプリ自体は動くので問題ありません。(いろんな都合で画像は配布が難しく...申し訳ないです)
私の環境では2枚の画像を/public以下に配置しました。
4de2ff6388f9fe6b4cf342955c85614a.png

7. エラーを直して再チャレンジ

先ほど同様にメッセージを送ってみます...
image.png

問題なく動いています

データベースにも反映されていますね
image.png


8. セキュリティを見直そう

詳細はぜひ調べてほしいのですが、現時点ではRLS(Row Level Security)というSupabaseでのセキュリティが無効になっています。悪意を持った人がアプリを見つけるとデータベースをやりたい放題されてしまうので、最小限保護をしましょう。SupabaseのSQL Editorに貼り付けしてください

-- 1. RLS 有効化
alter table public.chat_messages enable row level security;

-- 2. 読み取り:自分の行だけ
create policy "select_own"
  on public.chat_messages
  for select
  to authenticated
  using (user_id = auth.uid());

-- 3. 追加:自分の行だけ
create policy "insert_own"
  on public.chat_messages
  for insert
  to authenticated
  with check (user_id = auth.uid());

-- 更新・削除は不要なら作らない(= デフォルト拒否)

RLSなど気になったものは軽くでもいいので調べてみましょう

9. デプロイ(Vercel)

最後にデプロイをして終わりにします。まずビルドが通らないと間違いなくデプロイは失敗してしまうので

npm run build

こちらのコマンドで、ビルドをローカルで試します。ターミナルにこのような表示が出ればビルド成功です。エラーが出た際はAIに聞きつつ解決してみてください。


Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/chat
├ ƒ /auth/confirm
├ ƒ /auth/error
├ ○ /auth/forgot-password
├ ○ /auth/login
├ ○ /auth/sign-up
├ ○ /auth/sign-up-success
├ ƒ /protected/home
├ ƒ /protected/settings
└ ƒ /protected/update-password


ƒ Proxy (Middleware)(Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

問題なければgit pushも行いましょう

git add .
git commit -m "お好きなコミットメッセージ"
git push

ではVercelにデプロイを行います。VercelにWeb上からアクセスし、以下の手順でGithub上のレポジトリをデプロイしてください。

  1. Vercel → Add New → Project → Import Git Repositoryebi-chat(今回作成したgithubレポジトリ名) を選択

  2. Environment Variables を開き、.envの値を張り付けます

    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY
    • OPENAI_API_KEY
  3. Deployボタンを押して1分ほど待機

  4. Visit で動作確認(ログイン → チャット)

image.png


10. 追加実装(任意)

ここまでお疲れさまでした!今までの内容を深く理解するためにも、アプリに皆さんだけのオリジナル機能を付けてみてください。追加の機能開発の過程でコードやアプリ構造の理解が進むはずです

おわりに

ここまで読んでいただきありがとうございました。今回使っていただいたプロンプトの作り方も別の記事で公開予定です。そちらもぜひ読んでいただけるとうれしいです。


タグ

#Next.js #Supabase #Vercel #TailwindCSS #shadcnui #OpenAI #生成AI #フルスタック #初心者向け


2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?