Construire une application complète avec Firebase et Next.js 15 : Auth, Firestore et temps réel

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Firebase reste en 2026 l'une des plateformes backend les plus populaires pour les développeurs web. Combiné avec Next.js 15 et son App Router, il permet de créer des applications performantes avec authentification, base de données en temps réel et rendu côté serveur — le tout sans gérer de serveur backend.

Dans ce tutoriel, nous allons construire une application de notes collaboratives avec :

  • Authentification Google via Firebase Auth
  • Stockage des données dans Firestore
  • Synchronisation en temps réel entre les utilisateurs
  • Server Actions de Next.js 15 pour les opérations sécurisées
  • Déploiement sur Vercel

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • Un compte Google pour Firebase
  • Des connaissances de base en React et TypeScript
  • Un éditeur de code (VS Code recommandé)
  • npm ou pnpm comme gestionnaire de paquets

Ce que vous allez construire

Une application de notes collaboratives où les utilisateurs peuvent :

  • Se connecter avec leur compte Google
  • Créer, modifier et supprimer des notes
  • Voir les modifications des autres utilisateurs en temps réel
  • Organiser leurs notes par catégories
FonctionnalitéTechnologie
AuthentificationFirebase Auth (Google)
Base de donnéesCloud Firestore
Temps réelFirestore onSnapshot
FrontendNext.js 15 App Router
StylingTailwind CSS v4
DéploiementVercel

Étape 1 : Créer le projet Next.js

Commencez par initialiser un nouveau projet Next.js 15 avec TypeScript et Tailwind CSS :

npx create-next-app@latest firebase-notes-app --typescript --tailwind --app --src-dir --eslint
cd firebase-notes-app

Sélectionnez les options par défaut quand le CLI vous le demande. Ensuite, installez les dépendances Firebase :

npm install firebase firebase-admin
  • firebase : SDK client pour le navigateur (Auth, Firestore)
  • firebase-admin : SDK serveur pour les Server Actions et le middleware

Étape 2 : Configurer le projet Firebase

Créer un projet Firebase

  1. Rendez-vous sur Firebase Console
  2. Cliquez sur Ajouter un projet
  3. Nommez votre projet (ex : firebase-notes-app)
  4. Désactivez Google Analytics si vous n'en avez pas besoin
  5. Attendez la création du projet

Activer l'authentification

  1. Dans le menu latéral, allez dans Build > Authentication
  2. Cliquez sur Commencer
  3. Dans l'onglet Fournisseurs de connexion, activez Google
  4. Configurez l'email de support et cliquez Enregistrer

Créer la base de données Firestore

  1. Allez dans Build > Firestore Database
  2. Cliquez sur Créer une base de données
  3. Sélectionnez le mode production
  4. Choisissez la région la plus proche (ex : europe-west1 pour la Tunisie)

Obtenir les clés de configuration

  1. Allez dans Paramètres du projet > Général
  2. Dans la section Vos applications, cliquez sur l'icône Web (</>)
  3. Enregistrez votre application et copiez la configuration

Étape 3 : Configurer les variables d'environnement

Créez un fichier .env.local à la racine du projet :

# Firebase Client SDK
NEXT_PUBLIC_FIREBASE_API_KEY=votre-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=votre-projet.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=votre-projet-id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=votre-projet.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef
 
# Firebase Admin SDK
FIREBASE_ADMIN_PROJECT_ID=votre-projet-id
FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxx@votre-projet.iam.gserviceaccount.com
FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nvotre-cle-privee\n-----END PRIVATE KEY-----\n"

Ne commitez jamais vos clés Firebase Admin dans votre dépôt Git. Le fichier .env.local est déjà dans .gitignore par défaut avec Next.js.

Pour obtenir la clé privée Admin :

  1. Allez dans Paramètres du projet > Comptes de service
  2. Cliquez sur Générer une nouvelle clé privée
  3. Copiez les valeurs client_email et private_key du fichier JSON téléchargé

Étape 4 : Initialiser Firebase côté client

Créez le fichier de configuration Firebase pour le navigateur :

// src/lib/firebase.ts
import { initializeApp, getApps } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
 
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,
};
 
// Éviter la double initialisation en développement (hot reload)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
 
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
export const db = getFirestore(app);

La vérification getApps().length === 0 est essentielle avec Next.js car le hot reload en développement peut tenter de réinitialiser Firebase plusieurs fois.

Étape 5 : Configurer Firebase Admin côté serveur

Créez la configuration pour les opérations serveur :

// src/lib/firebase-admin.ts
import { initializeApp, getApps, cert } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { getFirestore } from "firebase-admin/firestore";
 
const adminConfig = {
  credential: cert({
    projectId: process.env.FIREBASE_ADMIN_PROJECT_ID,
    clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, "\n"),
  }),
};
 
const adminApp =
  getApps().length === 0 ? initializeApp(adminConfig) : getApps()[0];
 
export const adminAuth = getAuth(adminApp);
export const adminDb = getFirestore(adminApp);

Le replace(/\\n/g, "\n") est nécessaire car les variables d'environnement stockent les retours à la ligne comme des chaînes littérales \n.

Étape 6 : Créer le contexte d'authentification

Implémentez un provider React qui gère l'état d'authentification dans toute l'application :

// src/contexts/AuthContext.tsx
"use client";
 
import {
  createContext,
  useContext,
  useEffect,
  useState,
  type ReactNode,
} from "react";
import {
  onAuthStateChanged,
  signInWithPopup,
  signOut as firebaseSignOut,
  type User,
} from "firebase/auth";
import { auth, googleProvider } from "@/lib/firebase";
 
interface AuthContextType {
  user: User | null;
  loading: boolean;
  signInWithGoogle: () => Promise<void>;
  signOut: () => Promise<void>;
}
 
const AuthContext = createContext<AuthContextType | null>(null);
 
export function AuthProvider({ children }: { children: ReactNode }) {
  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 signInWithGoogle = async () => {
    try {
      await signInWithPopup(auth, googleProvider);
    } catch (error) {
      console.error("Erreur de connexion:", error);
      throw error;
    }
  };
 
  const signOut = async () => {
    try {
      await firebaseSignOut(auth);
    } catch (error) {
      console.error("Erreur de déconnexion:", error);
      throw error;
    }
  };
 
  return (
    <AuthContext.Provider value={{ user, loading, signInWithGoogle, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth doit être utilisé dans un AuthProvider");
  }
  return context;
}

Enveloppez votre layout principal avec le provider :

// src/app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body>
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}

Étape 7 : Construire le composant de connexion

Créez un composant de connexion élégant :

// src/components/LoginButton.tsx
"use client";
 
import { useAuth } from "@/contexts/AuthContext";
 
export function LoginButton() {
  const { user, loading, signInWithGoogle, signOut } = useAuth();
 
  if (loading) {
    return (
      <div className="animate-pulse h-10 w-32 bg-gray-200 rounded-lg" />
    );
  }
 
  if (user) {
    return (
      <div className="flex items-center gap-3">
        <img
          src={user.photoURL || "/default-avatar.png"}
          alt={user.displayName || "Avatar"}
          className="w-8 h-8 rounded-full"
        />
        <span className="text-sm font-medium">{user.displayName}</span>
        <button
          onClick={signOut}
          className="px-4 py-2 text-sm bg-red-500 text-white rounded-lg
                     hover:bg-red-600 transition-colors"
        >
          Déconnexion
        </button>
      </div>
    );
  }
 
  return (
    <button
      onClick={signInWithGoogle}
      className="flex items-center gap-2 px-6 py-3 bg-white border
                 border-gray-300 rounded-lg shadow-sm hover:shadow-md
                 transition-all duration-200"
    >
      <svg className="w-5 h-5" viewBox="0 0 24 24">
        <path
          fill="#4285F4"
          d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06
             5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
        />
        <path
          fill="#34A853"
          d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23
             1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99
             20.53 7.7 23 12 23z"
        />
        <path
          fill="#FBBC05"
          d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43
             8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
        />
        <path
          fill="#EA4335"
          d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45
             2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66
             2.84c.87-2.6 3.3-4.53 6.16-4.53z"
        />
      </svg>
      Se connecter avec Google
    </button>
  );
}

Étape 8 : Définir les types et la structure Firestore

Définissez les types TypeScript pour vos données :

// src/types/note.ts
export interface Note {
  id: string;
  title: string;
  content: string;
  category: string;
  userId: string;
  userDisplayName: string;
  userPhotoURL: string;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface NoteInput {
  title: string;
  content: string;
  category: string;
}
 
export const CATEGORIES = [
  "Personnel",
  "Travail",
  "Idées",
  "Tâches",
  "Autre",
] as const;

La structure Firestore sera :

notes (collection)
  └── {noteId} (document)
        ├── title: string
        ├── content: string
        ├── category: string
        ├── userId: string
        ├── userDisplayName: string
        ├── userPhotoURL: string
        ├── createdAt: Timestamp
        └── updatedAt: Timestamp

Étape 9 : Configurer les règles de sécurité Firestore

Avant d'écrire du code, configurez les règles de sécurité dans la console Firebase :

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    // Les notes sont lisibles par tous les utilisateurs authentifiés
    // mais modifiables uniquement par leur auteur
    match /notes/{noteId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null
                    && request.resource.data.userId == request.auth.uid;
      allow update, delete: if request.auth != null
                            && resource.data.userId == request.auth.uid;
    }
  }
}

Ne jamais laisser les règles en mode test (allow read, write: if true) en production. Cela expose toutes vos données à n'importe qui.

Ces règles garantissent que :

  • Seuls les utilisateurs connectés peuvent lire les notes
  • Un utilisateur ne peut créer des notes qu'avec son propre userId
  • Seul l'auteur peut modifier ou supprimer ses notes

Étape 10 : Créer les Server Actions pour les opérations CRUD

Utilisez les Server Actions de Next.js 15 pour les opérations d'écriture sécurisées :

// src/app/actions/notes.ts
"use server";
 
import { adminDb } from "@/lib/firebase-admin";
import { FieldValue } from "firebase-admin/firestore";
 
export async function createNote(data: {
  title: string;
  content: string;
  category: string;
  userId: string;
  userDisplayName: string;
  userPhotoURL: string;
}) {
  try {
    const docRef = await adminDb.collection("notes").add({
      ...data,
      createdAt: FieldValue.serverTimestamp(),
      updatedAt: FieldValue.serverTimestamp(),
    });
 
    return { success: true, id: docRef.id };
  } catch (error) {
    console.error("Erreur création note:", error);
    return { success: false, error: "Impossible de créer la note" };
  }
}
 
export async function updateNote(
  noteId: string,
  userId: string,
  data: { title?: string; content?: string; category?: string }
) {
  try {
    const noteRef = adminDb.collection("notes").doc(noteId);
    const noteDoc = await noteRef.get();
 
    if (!noteDoc.exists) {
      return { success: false, error: "Note introuvable" };
    }
 
    if (noteDoc.data()?.userId !== userId) {
      return { success: false, error: "Non autorisé" };
    }
 
    await noteRef.update({
      ...data,
      updatedAt: FieldValue.serverTimestamp(),
    });
 
    return { success: true };
  } catch (error) {
    console.error("Erreur mise à jour note:", error);
    return { success: false, error: "Impossible de mettre à jour la note" };
  }
}
 
export async function deleteNote(noteId: string, userId: string) {
  try {
    const noteRef = adminDb.collection("notes").doc(noteId);
    const noteDoc = await noteRef.get();
 
    if (!noteDoc.exists) {
      return { success: false, error: "Note introuvable" };
    }
 
    if (noteDoc.data()?.userId !== userId) {
      return { success: false, error: "Non autorisé" };
    }
 
    await noteRef.delete();
    return { success: true };
  } catch (error) {
    console.error("Erreur suppression note:", error);
    return { success: false, error: "Impossible de supprimer la note" };
  }
}

Les Server Actions vérifient toujours le userId avant toute modification. Cette double vérification (règles Firestore + Server Actions) offre une sécurité en profondeur.

Étape 11 : Implémenter l'écoute en temps réel

Créez un hook personnalisé qui écoute les changements Firestore en temps réel :

// src/hooks/useNotes.ts
"use client";
 
import { useEffect, useState } from "react";
import {
  collection,
  query,
  orderBy,
  onSnapshot,
  type Timestamp,
} from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { Note } from "@/types/note";
 
export function useNotes() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    const q = query(collection(db, "notes"), orderBy("updatedAt", "desc"));
 
    const unsubscribe = onSnapshot(
      q,
      (snapshot) => {
        const notesData = snapshot.docs.map((doc) => {
          const data = doc.data();
          return {
            id: doc.id,
            title: data.title,
            content: data.content,
            category: data.category,
            userId: data.userId,
            userDisplayName: data.userDisplayName,
            userPhotoURL: data.userPhotoURL,
            createdAt: (data.createdAt as Timestamp)?.toDate() || new Date(),
            updatedAt: (data.updatedAt as Timestamp)?.toDate() || new Date(),
          } satisfies Note;
        });
 
        setNotes(notesData);
        setLoading(false);
      },
      (err) => {
        console.error("Erreur écoute notes:", err);
        setError("Impossible de charger les notes");
        setLoading(false);
      }
    );
 
    return () => unsubscribe();
  }, []);
 
  return { notes, loading, error };
}

Le onSnapshot de Firestore établit une connexion WebSocket persistante. Chaque fois qu'un document de la collection notes est ajouté, modifié ou supprimé, le callback est déclenché automatiquement — sans polling ni rechargement de page.

Étape 12 : Construire le formulaire de création de notes

Créez le composant de formulaire avec validation :

// src/components/NoteForm.tsx
"use client";
 
import { useState, useTransition } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { createNote } from "@/app/actions/notes";
import { CATEGORIES } from "@/types/note";
 
export function NoteForm({ onSuccess }: { onSuccess?: () => void }) {
  const { user } = useAuth();
  const [isPending, startTransition] = useTransition();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [category, setCategory] = useState(CATEGORIES[0]);
 
  if (!user) return null;
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
 
    startTransition(async () => {
      const result = await createNote({
        title,
        content,
        category,
        userId: user.uid,
        userDisplayName: user.displayName || "Anonyme",
        userPhotoURL: user.photoURL || "",
      });
 
      if (result.success) {
        setTitle("");
        setContent("");
        setCategory(CATEGORIES[0]);
        onSuccess?.();
      }
    });
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4 p-6 bg-white
                                             rounded-xl shadow-sm border">
      <h2 className="text-lg font-semibold">Nouvelle note</h2>
 
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Titre de la note"
        required
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500 focus:border-transparent"
      />
 
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Contenu de la note..."
        required
        rows={4}
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500 focus:border-transparent resize-none"
      />
 
      <select
        value={category}
        onChange={(e) => setCategory(e.target.value)}
        className="w-full px-4 py-2 border rounded-lg focus:ring-2
                   focus:ring-blue-500"
      >
        {CATEGORIES.map((cat) => (
          <option key={cat} value={cat}>
            {cat}
          </option>
        ))}
      </select>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full py-3 bg-blue-600 text-white rounded-lg
                   font-medium hover:bg-blue-700 disabled:opacity-50
                   transition-colors"
      >
        {isPending ? "Création en cours..." : "Créer la note"}
      </button>
    </form>
  );
}

Notez l'utilisation de useTransition : c'est le pattern recommandé par React 19 et Next.js 15 pour appeler des Server Actions depuis un formulaire. Il gère automatiquement l'état de chargement sans useState supplémentaire.

Étape 13 : Afficher les notes avec mise à jour en temps réel

Créez le composant qui affiche les notes et se met à jour automatiquement :

// src/components/NotesList.tsx
"use client";
 
import { useState, useTransition } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useNotes } from "@/hooks/useNotes";
import { deleteNote } from "@/app/actions/notes";
import type { Note } from "@/types/note";
 
function NoteCard({ note }: { note: Note }) {
  const { user } = useAuth();
  const [isPending, startTransition] = useTransition();
  const isOwner = user?.uid === note.userId;
 
  const handleDelete = () => {
    if (!confirm("Supprimer cette note ?")) return;
 
    startTransition(async () => {
      await deleteNote(note.id, user!.uid);
    });
  };
 
  const timeAgo = getTimeAgo(note.updatedAt);
 
  return (
    <div className="p-5 bg-white rounded-xl shadow-sm border
                    hover:shadow-md transition-shadow">
      <div className="flex items-start justify-between">
        <div className="flex-1">
          <span className="inline-block px-2 py-1 text-xs font-medium
                          bg-blue-100 text-blue-700 rounded-full mb-2">
            {note.category}
          </span>
          <h3 className="text-lg font-semibold">{note.title}</h3>
          <p className="mt-2 text-gray-600 whitespace-pre-wrap">
            {note.content}
          </p>
        </div>
 
        {isOwner && (
          <button
            onClick={handleDelete}
            disabled={isPending}
            className="ml-3 p-2 text-red-500 hover:bg-red-50
                       rounded-lg transition-colors"
            aria-label="Supprimer"
          >
            {isPending ? "..." : "✕"}
          </button>
        )}
      </div>
 
      <div className="mt-4 flex items-center gap-2 text-sm text-gray-500">
        <img
          src={note.userPhotoURL || "/default-avatar.png"}
          alt={note.userDisplayName}
          className="w-5 h-5 rounded-full"
        />
        <span>{note.userDisplayName}</span>
        <span>·</span>
        <span>{timeAgo}</span>
      </div>
    </div>
  );
}
 
function getTimeAgo(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  if (seconds < 60) return "à l'instant";
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `il y a ${minutes}min`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `il y a ${hours}h`;
  const days = Math.floor(hours / 24);
  return `il y a ${days}j`;
}
 
export function NotesList() {
  const { notes, loading, error } = useNotes();
  const [filter, setFilter] = useState<string>("Tous");
 
  if (loading) {
    return (
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <div
            key={i}
            className="h-32 bg-gray-100 rounded-xl animate-pulse"
          />
        ))}
      </div>
    );
  }
 
  if (error) {
    return (
      <div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
    );
  }
 
  const filteredNotes =
    filter === "Tous"
      ? notes
      : notes.filter((n) => n.category === filter);
 
  return (
    <div>
      <div className="flex gap-2 mb-4 flex-wrap">
        {["Tous", "Personnel", "Travail", "Idées", "Tâches", "Autre"].map(
          (cat) => (
            <button
              key={cat}
              onClick={() => setFilter(cat)}
              className={`px-3 py-1 rounded-full text-sm transition-colors ${
                filter === cat
                  ? "bg-blue-600 text-white"
                  : "bg-gray-100 text-gray-700 hover:bg-gray-200"
              }`}
            >
              {cat}
            </button>
          )
        )}
      </div>
 
      {filteredNotes.length === 0 ? (
        <p className="text-center text-gray-500 py-8">
          Aucune note pour le moment. Créez votre première note !
        </p>
      ) : (
        <div className="space-y-4">
          {filteredNotes.map((note) => (
            <NoteCard key={note.id} note={note} />
          ))}
        </div>
      )}
    </div>
  );
}

Grâce au hook useNotes qui utilise onSnapshot, la liste se met à jour instantanément lorsqu'un autre utilisateur ajoute ou supprime une note — aucun rechargement nécessaire.

Étape 14 : Assembler la page principale

Combinez tous les composants dans la page d'accueil :

// src/app/page.tsx
"use client";
 
import { useAuth } from "@/contexts/AuthContext";
import { LoginButton } from "@/components/LoginButton";
import { NoteForm } from "@/components/NoteForm";
import { NotesList } from "@/components/NotesList";
 
export default function HomePage() {
  const { user, loading } = useAuth();
 
  return (
    <main className="min-h-screen bg-gray-50">
      <header className="bg-white border-b px-6 py-4">
        <div className="max-w-4xl mx-auto flex items-center justify-between">
          <h1 className="text-2xl font-bold">
            📝 Notes Collaboratives
          </h1>
          <LoginButton />
        </div>
      </header>
 
      <div className="max-w-4xl mx-auto px-6 py-8">
        {loading ? (
          <div className="flex justify-center py-12">
            <div className="animate-spin h-8 w-8 border-4 border-blue-600
                          border-t-transparent rounded-full" />
          </div>
        ) : !user ? (
          <div className="text-center py-16">
            <h2 className="text-3xl font-bold mb-4">
              Bienvenue sur Notes Collaboratives
            </h2>
            <p className="text-gray-600 mb-8">
              Connectez-vous pour créer et partager des notes en temps réel
            </p>
            <LoginButton />
          </div>
        ) : (
          <div className="grid gap-8 md:grid-cols-[350px_1fr]">
            <aside>
              <NoteForm />
            </aside>
            <section>
              <h2 className="text-xl font-semibold mb-4">
                Toutes les notes
              </h2>
              <NotesList />
            </section>
          </div>
        )}
      </div>
    </main>
  );
}

Étape 15 : Ajouter un middleware d'authentification (optionnel)

Pour protéger certaines routes côté serveur, ajoutez un middleware :

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  // Vérifier le cookie de session Firebase
  const session = request.cookies.get("__session");
 
  // Routes protégées
  const protectedPaths = ["/dashboard", "/settings"];
  const isProtected = protectedPaths.some((path) =>
    request.nextUrl.pathname.startsWith(path)
  );
 
  if (isProtected && !session) {
    return NextResponse.redirect(new URL("/", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

Dans notre application de notes, l'authentification est gérée côté client via le contexte React. Le middleware est utile si vous ajoutez des pages protégées rendues côté serveur.

Étape 16 : Optimiser les performances

Pagination avec Firestore

Pour les applications avec beaucoup de notes, implémentez la pagination :

// src/hooks/useNotesPaginated.ts
"use client";
 
import { useState, useEffect, useCallback } from "react";
import {
  collection,
  query,
  orderBy,
  limit,
  startAfter,
  onSnapshot,
  type QueryDocumentSnapshot,
  type DocumentData,
} from "firebase/firestore";
import { db } from "@/lib/firebase";
import type { Note } from "@/types/note";
 
const PAGE_SIZE = 10;
 
export function useNotesPaginated() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [lastDoc, setLastDoc] =
    useState<QueryDocumentSnapshot<DocumentData> | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(true);
 
  // Charger la première page
  useEffect(() => {
    const q = query(
      collection(db, "notes"),
      orderBy("updatedAt", "desc"),
      limit(PAGE_SIZE)
    );
 
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const data = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
        createdAt: doc.data().createdAt?.toDate() || new Date(),
        updatedAt: doc.data().updatedAt?.toDate() || new Date(),
      })) as Note[];
 
      setNotes(data);
      setLastDoc(snapshot.docs[snapshot.docs.length - 1] || null);
      setHasMore(snapshot.docs.length === PAGE_SIZE);
      setLoading(false);
    });
 
    return () => unsubscribe();
  }, []);
 
  const loadMore = useCallback(() => {
    if (!lastDoc || !hasMore) return;
 
    const q = query(
      collection(db, "notes"),
      orderBy("updatedAt", "desc"),
      startAfter(lastDoc),
      limit(PAGE_SIZE)
    );
 
    onSnapshot(q, (snapshot) => {
      const newNotes = snapshot.docs.map((doc) => ({
        id: doc.id,
        ...doc.data(),
        createdAt: doc.data().createdAt?.toDate() || new Date(),
        updatedAt: doc.data().updatedAt?.toDate() || new Date(),
      })) as Note[];
 
      setNotes((prev) => [...prev, ...newNotes]);
      setLastDoc(snapshot.docs[snapshot.docs.length - 1] || null);
      setHasMore(snapshot.docs.length === PAGE_SIZE);
    });
  }, [lastDoc, hasMore]);
 
  return { notes, loading, hasMore, loadMore };
}

Index Firestore

Créez un fichier firestore.indexes.json pour optimiser les requêtes :

{
  "indexes": [
    {
      "collectionGroup": "notes",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "category", "order": "ASCENDING" },
        { "fieldPath": "updatedAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "notes",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "updatedAt", "order": "DESCENDING" }
      ]
    }
  ]
}

Déployez les index avec :

npx firebase deploy --only firestore:indexes

Étape 17 : Déployer sur Vercel

Préparer le déploiement

  1. Poussez votre code sur GitHub/GitLab
  2. Connectez votre dépôt à Vercel
  3. Ajoutez les variables d'environnement dans les paramètres du projet Vercel
# Variables à configurer dans Vercel
NEXT_PUBLIC_FIREBASE_API_KEY
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
NEXT_PUBLIC_FIREBASE_PROJECT_ID
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
NEXT_PUBLIC_FIREBASE_APP_ID
FIREBASE_ADMIN_PROJECT_ID
FIREBASE_ADMIN_CLIENT_EMAIL
FIREBASE_ADMIN_PRIVATE_KEY

Configurer le domaine autorisé

Dans la console Firebase, ajoutez votre domaine Vercel aux domaines autorisés :

  1. Allez dans Authentication > Settings > Authorized domains
  2. Ajoutez votre-app.vercel.app
  3. Ajoutez votre domaine personnalisé si vous en avez un

Déployer

# Avec Vercel CLI
npm i -g vercel
vercel --prod
 
# Ou simplement pousser sur la branche main pour un déploiement automatique
git push origin main

Estimation des coûts Firebase

Firebase offre un plan gratuit généreux (Spark) :

RessourceGratuit (Spark)Payant (Blaze)
Auth50 000 utilisateurs/mois0,0055$/utilisateur
Firestore lectures50 000/jour0,06$/100 000
Firestore écritures20 000/jour0,18$/100 000
Firestore stockage1 Go0,18$/Go
Bande passante10 Go/mois0,12$/Go

Pour une application de notes avec quelques centaines d'utilisateurs, le plan gratuit sera largement suffisant.

Dépannage

Erreur "auth/popup-blocked"

Certains navigateurs bloquent les popups. Solution :

// Alternative : utiliser signInWithRedirect au lieu de signInWithPopup
import { signInWithRedirect } from "firebase/auth";
 
const signInWithGoogle = async () => {
  await signInWithRedirect(auth, googleProvider);
};

Erreur "Missing or insufficient permissions"

Vérifiez que :

  1. Les règles Firestore sont correctement déployées
  2. L'utilisateur est bien authentifié avant d'accéder aux données
  3. Le userId dans le document correspond bien à l'UID Firebase

Données non synchronisées en temps réel

Vérifiez que :

  • Vous utilisez onSnapshot et non getDocs
  • Le listener n'est pas désabonné prématurément (vérifiez le useEffect cleanup)
  • Les règles Firestore autorisent la lecture

Erreur "FIREBASE_ADMIN_PRIVATE_KEY" en production

La clé privée contient des caractères spéciaux. Assurez-vous de :

  1. Entourer la valeur de guillemets doubles dans .env.local
  2. Appliquer le replace(/\\n/g, "\n") dans la configuration Admin

Aller plus loin

Voici quelques idées pour étendre ce projet :

  • Recherche full-text : Intégrez Algolia ou Typesense pour la recherche dans les notes
  • Partage de notes : Ajoutez des permissions granulaires par note
  • Mode hors-ligne : Activez la persistance Firestore pour le fonctionnement offline
  • Notifications push : Utilisez Firebase Cloud Messaging pour notifier les mises à jour
  • Export PDF : Permettez l'export des notes au format PDF
  • Éditeur riche : Remplacez le textarea par TipTap ou Lexical

Conclusion

Dans ce tutoriel, vous avez appris à construire une application full-stack complète avec Firebase et Next.js 15. Nous avons couvert :

  1. L'authentification avec Firebase Auth et Google Sign-In
  2. Le stockage des données avec Cloud Firestore
  3. La synchronisation en temps réel avec onSnapshot
  4. Les Server Actions pour des opérations d'écriture sécurisées
  5. Les règles de sécurité pour protéger vos données
  6. La pagination pour gérer de gros volumes de données
  7. Le déploiement sur Vercel

Firebase, combiné avec Next.js 15, offre une stack puissante pour créer des applications interactives et collaboratives — sans avoir besoin de configurer un serveur backend, une base de données ou un système d'authentification manuellement. C'est un excellent choix pour les MVP, les projets personnels et les applications à petite ou moyenne échelle.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Maitriser Framer Motion : Guide Complet pour des Animations Epoustouflantes.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes