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

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 |
|---|---|
| Authentification | Firebase Auth (Google) |
| Base de données | Cloud Firestore |
| Temps réel | Firestore onSnapshot |
| Frontend | Next.js 15 App Router |
| Styling | Tailwind CSS v4 |
| Déploiement | Vercel |
É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-appSé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
- Rendez-vous sur Firebase Console
- Cliquez sur Ajouter un projet
- Nommez votre projet (ex :
firebase-notes-app) - Désactivez Google Analytics si vous n'en avez pas besoin
- Attendez la création du projet
Activer l'authentification
- Dans le menu latéral, allez dans Build > Authentication
- Cliquez sur Commencer
- Dans l'onglet Fournisseurs de connexion, activez Google
- Configurez l'email de support et cliquez Enregistrer
Créer la base de données Firestore
- Allez dans Build > Firestore Database
- Cliquez sur Créer une base de données
- Sélectionnez le mode production
- Choisissez la région la plus proche (ex :
europe-west1pour la Tunisie)
Obtenir les clés de configuration
- Allez dans Paramètres du projet > Général
- Dans la section Vos applications, cliquez sur l'icône Web (</>)
- 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 :
- Allez dans Paramètres du projet > Comptes de service
- Cliquez sur Générer une nouvelle clé privée
- Copiez les valeurs
client_emailetprivate_keydu 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
- Poussez votre code sur GitHub/GitLab
- Connectez votre dépôt à Vercel
- 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_KEYConfigurer le domaine autorisé
Dans la console Firebase, ajoutez votre domaine Vercel aux domaines autorisés :
- Allez dans Authentication > Settings > Authorized domains
- Ajoutez
votre-app.vercel.app - 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 mainEstimation des coûts Firebase
Firebase offre un plan gratuit généreux (Spark) :
| Ressource | Gratuit (Spark) | Payant (Blaze) |
|---|---|---|
| Auth | 50 000 utilisateurs/mois | 0,0055$/utilisateur |
| Firestore lectures | 50 000/jour | 0,06$/100 000 |
| Firestore écritures | 20 000/jour | 0,18$/100 000 |
| Firestore stockage | 1 Go | 0,18$/Go |
| Bande passante | 10 Go/mois | 0,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 :
- Les règles Firestore sont correctement déployées
- L'utilisateur est bien authentifié avant d'accéder aux données
- Le
userIddans le document correspond bien à l'UID Firebase
Données non synchronisées en temps réel
Vérifiez que :
- Vous utilisez
onSnapshotet nongetDocs - Le listener n'est pas désabonné prématurément (vérifiez le
useEffectcleanup) - 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 :
- Entourer la valeur de guillemets doubles dans
.env.local - 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 :
- L'authentification avec Firebase Auth et Google Sign-In
- Le stockage des données avec Cloud Firestore
- La synchronisation en temps réel avec
onSnapshot - Les Server Actions pour des opérations d'écriture sécurisées
- Les règles de sécurité pour protéger vos données
- La pagination pour gérer de gros volumes de données
- 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.
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

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

Construire une application temps réel avec Supabase et Next.js 15 : guide complet
Apprenez à construire une application full-stack en temps réel avec Supabase et Next.js 15 App Router. Ce guide couvre l'authentification, la base de données, Row Level Security et les abonnements temps réel.

Better Auth avec Next.js 15 : Le Guide Complet d'Authentification pour 2026
Apprenez à implémenter une authentification complète dans Next.js 15 avec Better Auth. Ce tutoriel couvre email/mot de passe, OAuth, sessions, protection des routes et contrôle d'accès basé sur les rôles.