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

# 【完全無料】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. プロジェクト作成

```bash
mkdir simple-chat-app
cd simple-chat-app
```

### 2. package.json

```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**
```dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package.json ./
RUN npm install

COPY . .

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

**docker-compose.yml**
```yaml
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](https://console.firebase.google.com/) にアクセス
2. 「プロジェクトを追加」でプロジェクト作成
3. Authentication → 「開始」→ メール/パスワード を有効化
4. プロジェクト設定から Web アプリを追加し、config をコピー

### 2. Firebase設定ファイル

**lib/firebase.ts**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
'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**
```typescript
'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**
```typescript
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環境構築
```bash
# 依存関係の問題が出た場合は一度クリーンアップ
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統合
```typescript
// 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開発のエッセンシャルな技術スタックを習得できます。特に、認証機能付きのリアルタイムアプリケーションの基本パターンを学べるため、より大きなプロジェクトへの応用も可能です。

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

## 参考リンク

- [Next.js Documentation](https://nextjs.org/docs)
- [Firebase Authentication](https://firebase.google.com/docs/auth)
- [Tailwind CSS](https://tailwindcss.com/docs)

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