【完全無料】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 プロジェクト作成
- Firebase Console にアクセス
- 「プロジェクトを追加」でプロジェクト作成
- Authentication → 「開始」→ メール/パスワード を有効化
- プロジェクト設定から 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
✅ 動作確認
- アカウント作成: メールアドレスとパスワードで新規登録
- ログイン: 作成したアカウントでログイン
- チャット: メッセージを送信してAI応答を確認
- ログアウト: ヘッダーからログアウト
🎯 学習ポイント
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に置き換えてください)