0
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?

More than 1 year has passed since last update.

簡単なAIとのチャットアプリ

0
Posted at

【完全無料】Next.js 14 + Firebase Auth でシンプルチャットアプリを作ってみた

はじめに

Next.js 14の App Router と Firebase Authentication を使って、シンプルなチャットアプリを作成しました。OpenAI APIは使わずダミー応答で実装しているため、完全無料で学習できます!

Docker環境で統一されているので、環境構築でハマることなく、すぐに開発を始められます。

🎯 この記事で学べること

  • Next.js 14 App Routerの基本的な使い方
  • Firebase Authenticationの実装方法
  • TypeScript + Tailwind CSSでのモダン開発
  • Docker を使った開発環境構築
  • API Routes の設計・実装

🚀 完成品

機能一覧

  • ✅ メール/パスワード認証(Firebase Auth)
  • ✅ AIチャット機能(ダミー応答)
  • ✅ リアルタイムメッセージング
  • ✅ レスポンシブデザイン
  • ✅ 認証状態に応じたルート保護

技術スタック

  • Next.js 14.2.6 (App Router)
  • React 18
  • TypeScript 5
  • Tailwind CSS
  • Firebase Authentication
  • Docker

📁 プロジェクト構造

simple-chat-app/
├── app/
│   ├── page.tsx                 # ログイン画面
│   ├── ChatPage/
│   │   └── page.tsx            # チャット画面
│   └── api/
│       └── chat/
│           └── route.ts        # AI応答API
├── hooks/
│   └── useAuth.ts              # 認証カスタムフック
├── lib/
│   └── firebase.ts             # Firebase設定
├── Dockerfile
├── docker-compose.yml
└── package.json

🔧 セットアップ

1. プロジェクト作成

mkdir simple-chat-app
cd simple-chat-app

2. package.json

{
  "name": "simple-chat-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "next": "14.2.6",
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0",
    "@types/react": "^18.0.0",
    "@types/react-dom": "^18.0.0",
    "tailwindcss": "^3.4.0",
    "autoprefixer": "^10.4.0",
    "postcss": "^8.4.0",
    "firebase": "^10.0.0"
  },
  "devDependencies": {
    "eslint": "^8.0.0",
    "eslint-config-next": "14.2.6"
  }
}

3. Docker設定

Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY package.json ./
RUN npm install

COPY . .

EXPOSE 3000
CMD ["npm", "run", "dev"]

docker-compose.yml

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
    stdin_open: true
    tty: true

🔥 Firebase設定

1. Firebase プロジェクト作成

  1. Firebase Console にアクセス
  2. 「プロジェクトを追加」でプロジェクト作成
  3. Authentication → 「開始」→ メール/パスワード を有効化
  4. プロジェクト設定から Web アプリを追加し、config をコピー

2. Firebase設定ファイル

lib/firebase.ts

import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID
}

const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)

3. 環境変数設定

.env.local

NEXT_PUBLIC_FIREBASE_API_KEY=your_api_key_here
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id

認証Hook作成

hooks/useAuth.ts

import { useState, useEffect } from 'react'
import { User, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'firebase/auth'
import { auth } from '../lib/firebase'

export const useAuth = () => {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      setUser(user)
      setLoading(false)
    })

    return () => unsubscribe()
  }, [])

  const login = async (email: string, password: string) => {
    return signInWithEmailAndPassword(auth, email, password)
  }

  const register = async (email: string, password: string) => {
    return createUserWithEmailAndPassword(auth, email, password)
  }

  const logout = async () => {
    return signOut(auth)
  }

  return {
    user,
    loading,
    login,
    register,
    logout
  }
}

🚪 ログイン画面実装

app/page.tsx

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '../hooks/useAuth'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [isRegister, setIsRegister] = useState(false)
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)
  
  const { login, register } = useAuth()
  const router = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')
    setLoading(true)

    try {
      if (isRegister) {
        await register(email, password)
      } else {
        await login(email, password)
      }
      router.push('/ChatPage')
    } catch (error: any) {
      setError(error.message || 'エラーが発生しました')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            {isRegister ? 'アカウント作成' : 'ログイン'}
          </h2>
        </div>
        
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div className="rounded-md shadow-sm space-y-4">
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">
                メールアドレス
              </label>
              <input
                id="email"
                name="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="your@email.com"
              />
            </div>
            
            <div>
              <label htmlFor="password" className="block text-sm font-medium text-gray-700">
                パスワード
              </label>
              <input
                id="password"
                name="password"
                type="password"
                required
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
                placeholder="6文字以上"
              />
            </div>
          </div>

          {error && (
            <div className="text-red-600 text-sm text-center">
              {error}
            </div>
          )}

          <div>
            <button
              type="submit"
              disabled={loading}
              className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white ${
                loading 
                  ? 'bg-gray-400 cursor-not-allowed' 
                  : 'bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
              }`}
            >
              {loading ? '処理中...' : (isRegister ? 'アカウント作成' : 'ログイン')}
            </button>
          </div>

          <div className="text-center">
            <button
              type="button"
              onClick={() => setIsRegister(!isRegister)}
              className="text-blue-600 hover:text-blue-500 text-sm"
            >
              {isRegister ? 'ログインに戻る' : 'アカウントを作成'}
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

💬 チャット画面実装

app/ChatPage/page.tsx

'use client'

import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '../../hooks/useAuth'

interface Message {
  id: string
  content: string
  isUser: boolean
}

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([
    { id: '1', content: 'こんにちは!何かお手伝いできることはありますか?', isUser: false }
  ])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  
  const { user, loading, logout } = useAuth()
  const router = useRouter()

  useEffect(() => {
    if (!loading && !user) {
      router.push('/')
    }
  }, [user, loading, router])

  if (loading) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
        <div className="text-lg">読み込み中...</div>
      </div>
    )
  }

  if (!user) {
    return null
  }

  const handleLogout = async () => {
    await logout()
    router.push('/')
  }

  const handleSend = async () => {
    if (!input.trim() || isLoading) return
    
    const userMessage: Message = {
      id: Date.now().toString(),
      content: input,
      isUser: true
    }
    
    setMessages(prev => [...prev, userMessage])
    setInput('')
    setIsLoading(true)
    
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message: input }),
      })

      const data = await response.json()

      if (response.ok) {
        const aiMessage: Message = {
          id: (Date.now() + 1).toString(),
          content: data.response,
          isUser: false
        }
        setMessages(prev => [...prev, aiMessage])
      } else {
        throw new Error(data.error || 'エラーが発生しました')
      }
    } catch (error) {
      const errorMessage: Message = {
        id: (Date.now() + 1).toString(),
        content: 'すみません、エラーが発生しました。もう一度お試しください。',
        isUser: false
      }
      setMessages(prev => [...prev, errorMessage])
      console.error('Chat error:', error)
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col">
      {/* ヘッダー */}
      <div className="bg-white shadow-sm p-4 border-b flex justify-between items-center">
        <h1 className="text-xl font-bold text-gray-800">Simple Chat</h1>
        <div className="flex items-center space-x-4">
          <span className="text-sm text-gray-600">{user.email}</span>
          <button
            onClick={handleLogout}
            className="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500"
          >
            ログアウト
          </button>
        </div>
      </div>

      {/* チャットエリア */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex ${message.isUser ? 'justify-end' : 'justify-start'}`}
          >
            <div
              className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
                message.isUser
                  ? 'bg-blue-500 text-white'
                  : 'bg-white text-gray-800 shadow-sm'
              }`}
            >
              {message.content}
            </div>
          </div>
        ))}
      </div>

      {/* 入力エリア */}
      <div className="bg-white border-t p-4">
        <div className="flex space-x-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && handleSend()}
            placeholder="メッセージを入力..."
            className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button
            onClick={handleSend}
            disabled={isLoading}
            className={`px-6 py-2 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
              isLoading 
                ? 'bg-gray-400 cursor-not-allowed' 
                : 'bg-blue-500 hover:bg-blue-600'
            }`}
          >
            {isLoading ? '送信中...' : '送信'}
          </button>
        </div>
      </div>
    </div>
  )
}

🤖 ダミーAI API実装

app/api/chat/route.ts

import { NextRequest, NextResponse } from 'next/server'

// ダミーAI応答パターン
const getDummyResponse = (message: string): string => {
  const responses = [
    `「${message}」について面白い質問ですね!`,
    `${message}に関してお答えします。これはダミー応答ですが、実際のAIのように動作します。`,
    `なるほど、「${message}」ですね。テスト環境では詳しく回答できませんが、システムは正常に動作しています!`,
    `「${message}」について考えてみました。実際のAI実装時には、もっと詳細な回答が可能になります。`,
    `興味深いご質問ですね。「${message}」に関する回答をシミュレートしています。`
  ]
  
  // メッセージの長さに基づいてランダム選択
  const index = message.length % responses.length
  return responses[index]
}

export async function POST(request: NextRequest) {
  try {
    const { message } = await request.json()

    if (!message) {
      return NextResponse.json(
        { error: 'メッセージが必要です' },
        { status: 400 }
      )
    }

    // 実際のAPI呼び出しをシミュレート(少し遅延を追加)
    await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000))

    const response = getDummyResponse(message)

    return NextResponse.json({ response })

  } catch (error) {
    console.error('API Error:', error)
    return NextResponse.json(
      { error: 'AI応答の生成に失敗しました' },
      { status: 500 }
    )
  }
}

🚀 起動手順

1. Docker環境構築

# 依存関係の問題が出た場合は一度クリーンアップ
rm -f package-lock.json
rm -rf node_modules

# Docker起動
docker-compose up --build

2. アクセス

http://localhost:3000

✅ 動作確認

  1. アカウント作成: メールアドレスとパスワードで新規登録
  2. ログイン: 作成したアカウントでログイン
  3. チャット: メッセージを送信してAI応答を確認
  4. ログアウト: ヘッダーからログアウト

🎯 学習ポイント

Next.js 14 App Router

  • ファイルベースルーティング
  • 'use client' ディレクティブの使い方
  • API Routes の実装

Firebase Authentication

  • 認証状態の監視(onAuthStateChanged
  • メール/パスワード認証の実装
  • 認証エラーのハンドリング

TypeScript

  • Interface定義によるデータ型の安全性
  • 非同期処理の型安全な実装
  • カスタムフック の型定義

React Hooks

  • useState による状態管理
  • useEffect による副作用の処理
  • カスタムフック による機能の分離

🔮 次のステップ

このアプリをベースに、以下の機能拡張が可能です:

実際のAI統合

// OpenAI API統合例
const completion = await openai.chat.completions.create({
  model: "gpt-3.5-turbo",
  messages: [{ role: "user", content: message }],
})

データ永続化

  • Firestore によるメッセージ履歴保存
  • ユーザープロフィール管理

UI/UX改善

  • ダークモード対応
  • 音声入力機能
  • ファイルアップロード機能

まとめ

Next.js 14 + Firebase Auth を使ったシンプルチャットアプリを完全無料で実装できました。

このプロジェクトを通じて、モダンなWeb開発のエッセンシャルな技術スタックを習得できます。特に、認証機能付きのリアルタイムアプリケーションの基本パターンを学べるため、より大きなプロジェクトへの応用も可能です。

ぜひ皆さんも試してみて、自分なりの機能拡張にチャレンジしてください!

参考リンク

GitHub リポジトリはこちら(実際のリポジトリURLに置き換えてください)

0
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
0
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?