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?

もうバックエンド開発で迷わない!Firebase×TypeScriptで構築する次世代サーバーレスアプリケーション完全ガイド

0
Last updated at Posted at 2025-08-24

はじめに

バックエンドの環境構築で時間を無駄にしていませんか?

「サーバーのセットアップに丸一日かかった」「認証システムの実装で躓いた」「リアルタイム機能の開発が複雑すぎる」そんな経験ありませんか?

この記事では、Googleが提供するFirebaseプラットフォームとTypeScriptを組み合わせて、わずか数時間で本格的なWebアプリケーションを構築する方法を解説します。サーバー管理不要、スケーリング自動、そして型安全な開発環境を手に入れましょう!

🎯 この記事で学べること

  • Firebaseの主要サービスと特徴の理解
  • Firebase SDK v9のTypeScript実装パターン
  • Authentication、Firestore、Cloud Functionsの実践的な使い方
  • リアルタイム機能の実装方法
  • 本番環境デプロイのベストプラクティス

💡 Firebaseとは?開発者が知るべき5つの核心機能

Firebaseは、Googleが提供するBaaS(Backend as a Service)プラットフォームです。従来のバックエンド開発で必要だった複雑な設定を、すべてGoogleが肩代わりしてくれます。

主要サービス一覧

サービス名 用途 特徴
Authentication ユーザー認証 SNSログイン、メール認証、匿名認証対応
Firestore NoSQLデータベース リアルタイム同期、オフライン対応
Cloud Functions サーバーレス関数 イベント駆動、自動スケーリング
Hosting 静的サイトホスティング CDN、カスタムドメイン対応
Storage ファイルストレージ 画像・動画の自動最適化

環境構築

1. プロジェクト初期化

package.json
{
  "name": "firebase-typescript-app",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "serve": "firebase emulators:start"
  },
  "dependencies": {
    "firebase": "^10.7.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.43",
    "@types/react-dom": "^18.2.17",
    "typescript": "^5.2.2",
    "vite": "^5.0.8",
    "firebase-tools": "^13.0.1"
  }
}

2. Firebase CLI インストール&ログイン

npm install -g firebase-tools
firebase login
firebase init

3. Firebase設定ファイル

src/lib/firebase.ts
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
import { getFirestore } from 'firebase/firestore'
import { getFunctions } from 'firebase/functions'

const firebaseConfig = {
  apiKey: process.env.VITE_FIREBASE_API_KEY,
  authDomain: process.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: process.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.VITE_FIREBASE_APP_ID
}

const app = initializeApp(firebaseConfig)

export const auth = getAuth(app)
export const db = getFirestore(app)
export const functions = getFunctions(app)
export default app

4. 環境変数設定

.env
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abcdef123456

Firebase Authentication

1. ユーザー認証のTypeScript実装

src/hooks/useAuth.ts
import { useState, useEffect } from 'react'
import { 
  User,
  signInWithEmailAndPassword,
  createUserWithEmailAndPassword,
  signInWithPopup,
  GoogleAuthProvider,
  signOut as firebaseSignOut,
  onAuthStateChanged
} from 'firebase/auth'
import { auth } from '../lib/firebase'

interface AuthState {
  user: User | null
  loading: boolean
  error: string | null
}

export const useAuth = () => {
  const [state, setState] = useState<AuthState>({
    user: null,
    loading: true,
    error: null
  })

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      setState({
        user,
        loading: false,
        error: null
      })
    })

    return unsubscribe
  }, [])

  const signInWithEmail = async (email: string, password: string) => {
    try {
      setState(prev => ({ ...prev, loading: true, error: null }))
      await signInWithEmailAndPassword(auth, email, password)
    } catch (error) {
      setState(prev => ({ 
        ...prev, 
        loading: false, 
        error: error instanceof Error ? error.message : '認証エラーが発生しました' 
      }))
    }
  }

  const signUpWithEmail = async (email: string, password: string) => {
    try {
      setState(prev => ({ ...prev, loading: true, error: null }))
      await createUserWithEmailAndPassword(auth, email, password)
    } catch (error) {
      setState(prev => ({ 
        ...prev, 
        loading: false, 
        error: error instanceof Error ? error.message : '登録エラーが発生しました' 
      }))
    }
  }

  const signInWithGoogle = async () => {
    try {
      setState(prev => ({ ...prev, loading: true, error: null }))
      const provider = new GoogleAuthProvider()
      await signInWithPopup(auth, provider)
    } catch (error) {
      setState(prev => ({ 
        ...prev, 
        loading: false, 
        error: error instanceof Error ? error.message : 'Google認証エラーが発生しました' 
      }))
    }
  }

  const signOut = async () => {
    try {
      await firebaseSignOut(auth)
    } catch (error) {
      setState(prev => ({ 
        ...prev, 
        error: error instanceof Error ? error.message : 'ログアウトエラーが発生しました' 
      }))
    }
  }

  return {
    ...state,
    signInWithEmail,
    signUpWithEmail,
    signInWithGoogle,
    signOut
  }
}

2. 認証コンポーネントの実装

src/components/Auth.tsx
import React, { useState } from 'react'
import { useAuth } from '../hooks/useAuth'

const Auth: React.FC = () => {
  const [isSignUp, setIsSignUp] = useState(false)
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  
  const { user, loading, error, signInWithEmail, signUpWithEmail, signInWithGoogle, signOut } = useAuth()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    if (isSignUp) {
      await signUpWithEmail(email, password)
    } else {
      await signInWithEmail(email, password)
    }
  }

  if (loading) return <div>認証状態を確認中...</div>

  if (user) {
    return (
      <div>
        <h2>ようこそ{user.displayName || user.email}さん</h2>
        <button onClick={signOut}>ログアウト</button>
      </div>
    )
  }

  return (
    <div>
      <h2>{isSignUp ? 'アカウント作成' : 'ログイン'}</h2>
      
      {error && <div style={{ color: 'red' }}>{error}</div>}
      
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="メールアドレス"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="パスワード"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
        <button type="submit">
          {isSignUp ? 'アカウント作成' : 'ログイン'}
        </button>
      </form>
      
      <button onClick={signInWithGoogle}>
        Googleでログイン
      </button>
      
      <button onClick={() => setIsSignUp(!isSignUp)}>
        {isSignUp ? 'ログインに切り替え' : 'アカウント作成に切り替え'}
      </button>
    </div>
  )
}

export default Auth

Firestore Database

型安全なリアルタイムデータベース

1. データモデルの型定義

src/types/index.ts
export interface Post {
  id: string
  title: string
  content: string
  authorId: string
  authorName: string
  createdAt: Date
  updatedAt: Date
  tags: string[]
  published: boolean
}

export interface Comment {
  id: string
  postId: string
  authorId: string
  authorName: string
  content: string
  createdAt: Date
}

export interface User {
  id: string
  displayName: string
  email: string
  photoURL?: string
  createdAt: Date
}

2. Firestoreクライアントクラス

src/lib/firestore.ts
import {
  collection,
  doc,
  getDocs,
  getDoc,
  addDoc,
  updateDoc,
  deleteDoc,
  query,
  where,
  orderBy,
  limit,
  onSnapshot,
  serverTimestamp,
  Timestamp
} from 'firebase/firestore'
import { db } from './firebase'
import type { Post, Comment, User } from '../types'

class FirestoreService {
  // Posts Collection
  async createPost(post: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>) {
    try {
      const docRef = await addDoc(collection(db, 'posts'), {
        ...post,
        createdAt: serverTimestamp(),
        updatedAt: serverTimestamp()
      })
      return docRef.id
    } catch (error) {
      console.error('投稿作成エラー:', error)
      throw error
    }
  }

  async getPosts(userId?: string): Promise<Post[]> {
    try {
      const postsRef = collection(db, 'posts')
      let q = query(
        postsRef,
        orderBy('createdAt', 'desc'),
        limit(50)
      )

      if (userId) {
        q = query(q, where('authorId', '==', userId))
      }

      const querySnapshot = await getDocs(q)
      return querySnapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
        createdAt: (doc.data().createdAt as Timestamp).toDate(),
        updatedAt: (doc.data().updatedAt as Timestamp).toDate()
      } as Post))
    } catch (error) {
      console.error('投稿取得エラー:', error)
      throw error
    }
  }

  async getPost(postId: string): Promise<Post | null> {
    try {
      const docRef = doc(db, 'posts', postId)
      const docSnap = await getDoc(docRef)
      
      if (docSnap.exists()) {
        const data = docSnap.data()
        return {
          id: docSnap.id,
          ...data,
          createdAt: (data.createdAt as Timestamp).toDate(),
          updatedAt: (data.updatedAt as Timestamp).toDate()
        } as Post
      }
      return null
    } catch (error) {
      console.error('投稿取得エラー:', error)
      throw error
    }
  }

  async updatePost(postId: string, updates: Partial<Post>) {
    try {
      const docRef = doc(db, 'posts', postId)
      await updateDoc(docRef, {
        ...updates,
        updatedAt: serverTimestamp()
      })
    } catch (error) {
      console.error('投稿更新エラー:', error)
      throw error
    }
  }

  async deletePost(postId: string) {
    try {
      const docRef = doc(db, 'posts', postId)
      await deleteDoc(docRef)
    } catch (error) {
      console.error('投稿削除エラー:', error)
      throw error
    }
  }

  // リアルタイム購読
  subscribeToPostsRealtime(callback: (posts: Post[]) => void, userId?: string) {
    const postsRef = collection(db, 'posts')
    let q = query(
      postsRef,
      orderBy('createdAt', 'desc'),
      limit(50)
    )

    if (userId) {
      q = query(q, where('authorId', '==', userId))
    }

    return onSnapshot(q, (querySnapshot) => {
      const posts: Post[] = querySnapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
        createdAt: (doc.data().createdAt as Timestamp).toDate(),
        updatedAt: (doc.data().updatedAt as Timestamp).toDate()
      } as Post))
      callback(posts)
    })
  }
}

export const firestoreService = new FirestoreService()

3. Firestoreを使用するReactコンポーネント

src/components/PostList.tsx
import React, { useState, useEffect } from 'react'
import { firestoreService } from '../lib/firestore'
import { useAuth } from '../hooks/useAuth'
import type { Post } from '../types'

const PostList: React.FC = () => {
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)
  const [newPost, setNewPost] = useState({ title: '', content: '', tags: '' })
  const { user } = useAuth()

  useEffect(() => {
    if (!user) return

    // リアルタイム購読
    const unsubscribe = firestoreService.subscribeToPostsRealtime((posts) => {
      setPosts(posts)
      setLoading(false)
    })

    return unsubscribe
  }, [user])

  const handleCreatePost = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!user || !newPost.title.trim()) return

    try {
      await firestoreService.createPost({
        title: newPost.title,
        content: newPost.content,
        authorId: user.uid,
        authorName: user.displayName || user.email || '匿名ユーザー',
        tags: newPost.tags.split(',').map(tag => tag.trim()).filter(Boolean),
        published: true
      })
      
      setNewPost({ title: '', content: '', tags: '' })
    } catch (error) {
      console.error('投稿作成に失敗しました:', error)
    }
  }

  if (loading) return <div>読み込み中...</div>

  return (
    <div>
      <h2>投稿一覧</h2>
      
      {user && (
        <form onSubmit={handleCreatePost}>
          <h3>新しい投稿</h3>
          <input
            type="text"
            placeholder="タイトル"
            value={newPost.title}
            onChange={(e) => setNewPost(prev => ({ ...prev, title: e.target.value }))}
            required
          />
          <textarea
            placeholder="内容"
            value={newPost.content}
            onChange={(e) => setNewPost(prev => ({ ...prev, content: e.target.value }))}
            rows={4}
          />
          <input
            type="text"
            placeholder="タグ (カンマ区切り)"
            value={newPost.tags}
            onChange={(e) => setNewPost(prev => ({ ...prev, tags: e.target.value }))}
          />
          <button type="submit">投稿する</button>
        </form>
      )}

      <div>
        {posts.map(post => (
          <div key={post.id} style={{ border: '1px solid #ccc', margin: '10px', padding: '10px' }}>
            <h3>{post.title}</h3>
            <p>{post.content}</p>
            <small>
              作成者: {post.authorName} | 
              作成日: {post.createdAt.toLocaleDateString()}
            </small>
            {post.tags.length > 0 && (
              <div>
                タグ: {post.tags.map(tag => (
                  <span key={tag} style={{ backgroundColor: '#f0f0f0', padding: '2px 6px', margin: '2px', borderRadius: '3px' }}>
                    {tag}
                  </span>
                ))}
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  )
}

export default PostList

Cloud Functions:サーバーレス関数の実装

1. Functions プロジェクトセットアップ

functions/src/index.ts
import { onRequest, onCall } from 'firebase-functions/v2/https'
import { onDocumentCreated, onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { initializeApp } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { getAuth } from 'firebase-admin/auth'

initializeApp()

const db = getFirestore()
const auth = getAuth()

// HTTP関数:投稿の統計情報を取得
export const getPostStats = onRequest({ cors: true }, async (req, res) => {
  try {
    const postsSnapshot = await db.collection('posts').get()
    const publishedCount = postsSnapshot.docs.filter(doc => doc.data().published).length
    const totalCount = postsSnapshot.size
    
    const authorsSnapshot = await db.collection('posts')
      .where('published', '==', true)
      .get()
    
    const uniqueAuthors = new Set(authorsSnapshot.docs.map(doc => doc.data().authorId))
    
    res.json({
      totalPosts: totalCount,
      publishedPosts: publishedCount,
      uniqueAuthors: uniqueAuthors.size
    })
  } catch (error) {
    console.error('統計取得エラー:', error)
    res.status(500).json({ error: '統計情報の取得に失敗しました' })
  }
})

// Callable関数:ユーザープロファイル更新
export const updateUserProfile = onCall(async (request) => {
  const { auth: userAuth, data } = request
  
  if (!userAuth) {
    throw new Error('認証が必要です')
  }

  try {
    const { displayName, photoURL } = data
    
    // Firebase Auth のプロファイル更新
    await auth.updateUser(userAuth.uid, {
      displayName,
      photoURL
    })
    
    // Firestore のユーザードキュメント更新
    await db.collection('users').doc(userAuth.uid).set({
      displayName,
      photoURL,
      email: userAuth.token.email,
      updatedAt: new Date()
    }, { merge: true })
    
    return { success: true, message: 'プロファイルが更新されました' }
  } catch (error) {
    console.error('プロファイル更新エラー:', error)
    throw new Error('プロファイルの更新に失敗しました')
  }
})

// Firestore トリガー:投稿作成時の通知
export const onPostCreated = onDocumentCreated('posts/{postId}', async (event) => {
  const snapshot = event.data
  if (!snapshot) return

  const post = snapshot.data()
  const postId = snapshot.id

  try {
    // 投稿作成の通知ログを作成
    await db.collection('notifications').add({
      type: 'new_post',
      postId,
      authorId: post.authorId,
      authorName: post.authorName,
      postTitle: post.title,
      createdAt: new Date()
    })

    console.log(`新しい投稿の通知を作成しました: ${postId}`)
  } catch (error) {
    console.error('通知作成エラー:', error)
  }
})

// Firestore トリガー:投稿更新時の処理
export const onPostUpdated = onDocumentUpdated('posts/{postId}', async (event) => {
  const beforeData = event.data?.before.data()
  const afterData = event.data?.after.data()
  
  if (!beforeData || !afterData) return

  // 公開状態が変更された場合
  if (beforeData.published !== afterData.published) {
    const postId = event.params.postId

    try {
      await db.collection('post_history').add({
        postId,
        action: afterData.published ? 'published' : 'unpublished',
        authorId: afterData.authorId,
        timestamp: new Date()
      })

      console.log(`投稿の公開状態が変更されました: ${postId} -> ${afterData.published}`)
    } catch (error) {
      console.error('履歴記録エラー:', error)
    }
  }
})

2. フロントエンドからCloud Functionsを呼び出し

src/hooks/useFunctions.ts
import { httpsCallable } from 'firebase/functions'
import { functions } from '../lib/firebase'

interface UpdateProfileData {
  displayName: string
  photoURL?: string
}

interface UpdateProfileResult {
  success: boolean
  message: string
}

export const useFunctions = () => {
  const updateUserProfile = httpsCallable<UpdateProfileData, UpdateProfileResult>(
    functions,
    'updateUserProfile'
  )

  const callUpdateProfile = async (data: UpdateProfileData) => {
    try {
      const result = await updateUserProfile(data)
      return result.data
    } catch (error) {
      console.error('プロファイル更新エラー:', error)
      throw error
    }
  }

  const getPostStats = async () => {
    try {
      const response = await fetch(`https://your-region-your-project.cloudfunctions.net/getPostStats`)
      return await response.json()
    } catch (error) {
      console.error('統計取得エラー:', error)
      throw error
    }
  }

  return {
    callUpdateProfile,
    getPostStats
  }
}

デプロイとセキュリティルール

1. Firestoreセキュリティルール

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Users collection
    match /users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
      allow read: if request.auth != null;
    }
    
    // Posts collection
    match /posts/{postId} {
      allow read: if resource.data.published == true || 
                     (request.auth != null && request.auth.uid == resource.data.authorId);
      allow create: if request.auth != null && 
                       request.auth.uid == request.resource.data.authorId;
      allow update: if request.auth != null && 
                       request.auth.uid == resource.data.authorId;
      allow delete: if request.auth != null && 
                       request.auth.uid == resource.data.authorId;
    }
    
    // Comments collection
    match /comments/{commentId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null && 
                       request.auth.uid == request.resource.data.authorId;
      allow update, delete: if request.auth != null && 
                               request.auth.uid == resource.data.authorId;
    }
    
    // Notifications collection (read-only for users)
    match /notifications/{notificationId} {
      allow read: if request.auth != null;
    }
  }
}

2. Firebase Hosting設定

firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ],
    "headers": [
      {
        "source": "**/*.@(js|css)",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "max-age=31536000"
          }
        ]
      }
    ]
  },
  "functions": {
    "source": "functions",
    "ignore": [
      "node_modules",
      ".git",
      "firebase-debug.log",
      "firebase-debug.*.log"
    ]
  },
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  }
}

3. デプロイ

# ビルド
npm run build

# Functions デプロイ
firebase deploy --only functions

# Hosting デプロイ
firebase deploy --only hosting

# すべてデプロイ
firebase deploy

まとめ

Firebase × TypeScriptの組み合わせで、以下を実現できました:

認証システム - メール認証、SNSログイン、状態管理
リアルタイムデータベース - 型安全なFirestore操作
サーバーレス関数 - イベント駆動の処理
セキュリティルール - データアクセス制御
本番デプロイ - CDN、カスタムドメイン対応

この実装パターンを使えば、サーバー管理不要で スケーラブルなアプリケーションをすぐに構築開始できますね!

次のステップとして、Firebase Analytics や Cloud Messaging を組み込んで、さらに高機能なアプリケーションを作ってみてください!

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?