بناء تطبيق كامل مع Firebase و Next.js 15: المصادقة، Firestore والتحديث الفوري

تظل Firebase في عام 2026 واحدة من أكثر منصات الخلفية شعبية لمطوري الويب. عند دمجها مع Next.js 15 و App Router الخاص به، يمكنك إنشاء تطبيقات عالية الأداء مع مصادقة وقاعدة بيانات فورية وعرض من جانب الخادم — كل ذلك دون إدارة خادم خلفي.
في هذا الدرس، سنبني تطبيق ملاحظات تعاونية يتضمن:
- مصادقة Google عبر Firebase Auth
- تخزين البيانات في Firestore
- مزامنة فورية بين المستخدمين
- Server Actions من Next.js 15 للعمليات الآمنة
- نشر على Vercel
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت
- حساب Google لاستخدام Firebase
- معرفة أساسية بـ React و TypeScript
- محرر أكواد (يُنصح بـ VS Code)
- npm أو pnpm كمدير حزم
ما الذي ستبنيه
تطبيق ملاحظات تعاونية حيث يمكن للمستخدمين:
- تسجيل الدخول بحساب Google
- إنشاء وتعديل وحذف الملاحظات
- رؤية تعديلات المستخدمين الآخرين بشكل فوري
- تنظيم الملاحظات حسب الفئات
| الميزة | التقنية |
|---|---|
| المصادقة | Firebase Auth (Google) |
| قاعدة البيانات | Cloud Firestore |
| التحديث الفوري | Firestore onSnapshot |
| الواجهة الأمامية | Next.js 15 App Router |
| التنسيق | Tailwind CSS v4 |
| النشر | Vercel |
الخطوة 1: إنشاء مشروع Next.js
ابدأ بتهيئة مشروع Next.js 15 جديد مع TypeScript و Tailwind CSS:
npx create-next-app@latest firebase-notes-app --typescript --tailwind --app --src-dir --eslint
cd firebase-notes-appاختر الخيارات الافتراضية عندما يطلب منك CLI ذلك. ثم ثبّت مكتبات Firebase:
npm install firebase firebase-admin- firebase: حزمة SDK للعميل في المتصفح (Auth، Firestore)
- firebase-admin: حزمة SDK للخادم لـ Server Actions والـ middleware
الخطوة 2: إعداد مشروع Firebase
إنشاء مشروع Firebase
- انتقل إلى Firebase Console
- انقر على إضافة مشروع
- سمِّ مشروعك (مثلاً:
firebase-notes-app) - عطّل Google Analytics إذا لم تكن بحاجة إليه
- انتظر إنشاء المشروع
تفعيل المصادقة
- في القائمة الجانبية، انتقل إلى Build > Authentication
- انقر على البدء
- في علامة التبويب موفرو تسجيل الدخول، فعّل Google
- اضبط بريد الدعم الإلكتروني وانقر حفظ
إنشاء قاعدة بيانات Firestore
- انتقل إلى Build > Firestore Database
- انقر على إنشاء قاعدة بيانات
- اختر وضع الإنتاج
- اختر المنطقة الأقرب (مثلاً:
europe-west1لتونس)
الحصول على مفاتيح التهيئة
- انتقل إلى إعدادات المشروع > عام
- في قسم تطبيقاتك، انقر على أيقونة الويب (</>)
- سجّل تطبيقك وانسخ إعدادات التهيئة
الخطوة 3: إعداد متغيرات البيئة
أنشئ ملف .env.local في جذر المشروع:
# Firebase Client SDK
NEXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.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=your-project-id
FIREBASE_ADMIN_CLIENT_EMAIL=firebase-adminsdk-xxx@your-project.iam.gserviceaccount.com
FIREBASE_ADMIN_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour-private-key\n-----END PRIVATE KEY-----\n"لا تقم أبداً بإضافة مفاتيح Firebase Admin إلى مستودع Git الخاص بك. ملف .env.local موجود بالفعل في .gitignore افتراضياً مع Next.js.
للحصول على المفتاح الخاص للـ Admin:
- انتقل إلى إعدادات المشروع > حسابات الخدمة
- انقر على إنشاء مفتاح خاص جديد
- انسخ قيم
client_emailوprivate_keyمن ملف JSON الذي تم تنزيله
الخطوة 4: تهيئة Firebase من جانب العميل
أنشئ ملف تهيئة Firebase للمتصفح:
// 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,
};
// منع التهيئة المزدوجة في وضع التطوير (إعادة التحميل السريع)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
export const db = getFirestore(app);التحقق من getApps().length === 0 ضروري مع Next.js لأن إعادة التحميل السريع في وضع التطوير قد تحاول إعادة تهيئة Firebase عدة مرات.
الخطوة 5: إعداد Firebase Admin من جانب الخادم
أنشئ إعدادات لعمليات الخادم:
// 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);replace(/\\n/g, "\n") ضروري لأن متغيرات البيئة تخزن أسطر جديدة كنصوص حرفية \n.
الخطوة 6: إنشاء سياق المصادقة
قم بتنفيذ provider في React يدير حالة المصادقة عبر التطبيق بأكمله:
// 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("خطأ في تسجيل الدخول:", error);
throw error;
}
};
const signOut = async () => {
try {
await firebaseSignOut(auth);
} catch (error) {
console.error("خطأ في تسجيل الخروج:", 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 داخل AuthProvider");
}
return context;
}قم بتغليف الـ layout الرئيسي بالـ provider:
// src/app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ar" dir="rtl">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}الخطوة 7: بناء مكون تسجيل الدخول
أنشئ مكون تسجيل دخول أنيق:
// 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 || "الصورة الشخصية"}
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"
>
تسجيل الخروج
</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>
تسجيل الدخول بحساب Google
</button>
);
}الخطوة 8: تعريف الأنواع وبنية Firestore
حدد أنواع TypeScript لبياناتك:
// 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 = [
"شخصي",
"عمل",
"أفكار",
"مهام",
"أخرى",
] as const;ستكون بنية Firestore كالتالي:
notes (مجموعة)
└── {noteId} (مستند)
├── title: string
├── content: string
├── category: string
├── userId: string
├── userDisplayName: string
├── userPhotoURL: string
├── createdAt: Timestamp
└── updatedAt: Timestamp
الخطوة 9: إعداد قواعد أمان Firestore
قبل كتابة الكود، اضبط قواعد الأمان في وحدة تحكم Firebase:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// الملاحظات قابلة للقراءة من قبل جميع المستخدمين المصادق عليهم
// لكن قابلة للتعديل فقط من قبل صاحبها
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;
}
}
}
لا تترك القواعد أبداً في وضع الاختبار (allow read, write: if true) في الإنتاج. هذا يعرض جميع بياناتك لأي شخص.
تضمن هذه القواعد أن:
- المستخدمون المصادق عليهم فقط يمكنهم قراءة الملاحظات
- المستخدم لا يمكنه إنشاء ملاحظات إلا بـ
userIdالخاص به - المؤلف فقط يمكنه تعديل أو حذف ملاحظاته
الخطوة 10: إنشاء Server Actions لعمليات CRUD
استخدم Server Actions من Next.js 15 لعمليات الكتابة الآمنة:
// 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("خطأ في إنشاء الملاحظة:", error);
return { success: false, error: "تعذر إنشاء الملاحظة" };
}
}
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: "الملاحظة غير موجودة" };
}
if (noteDoc.data()?.userId !== userId) {
return { success: false, error: "غير مصرح" };
}
await noteRef.update({
...data,
updatedAt: FieldValue.serverTimestamp(),
});
return { success: true };
} catch (error) {
console.error("خطأ في تحديث الملاحظة:", error);
return { success: false, error: "تعذر تحديث الملاحظة" };
}
}
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: "الملاحظة غير موجودة" };
}
if (noteDoc.data()?.userId !== userId) {
return { success: false, error: "غير مصرح" };
}
await noteRef.delete();
return { success: true };
} catch (error) {
console.error("خطأ في حذف الملاحظة:", error);
return { success: false, error: "تعذر حذف الملاحظة" };
}
}تتحقق Server Actions دائماً من userId قبل أي تعديل. هذا التحقق المزدوج (قواعد Firestore + Server Actions) يوفر أماناً متعدد الطبقات.
الخطوة 11: تنفيذ الاستماع الفوري
أنشئ hook مخصص يستمع لتغييرات Firestore في الوقت الفعلي:
// 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("خطأ في الاستماع للملاحظات:", err);
setError("تعذر تحميل الملاحظات");
setLoading(false);
}
);
return () => unsubscribe();
}, []);
return { notes, loading, error };
}يُنشئ onSnapshot من Firestore اتصال WebSocket مستمر. في كل مرة يتم فيها إضافة أو تعديل أو حذف مستند في مجموعة notes، يتم تشغيل callback تلقائياً — بدون استطلاع أو إعادة تحميل الصفحة.
الخطوة 12: بناء نموذج إنشاء الملاحظات
أنشئ مكون النموذج مع التحقق:
// 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 || "مجهول",
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">ملاحظة جديدة</h2>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="عنوان الملاحظة"
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="محتوى الملاحظة..."
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 ? "جاري الإنشاء..." : "إنشاء الملاحظة"}
</button>
</form>
);
}لاحظ استخدام useTransition: هذا هو النمط الموصى به من React 19 و Next.js 15 لاستدعاء Server Actions من نموذج. يدير تلقائياً حالة التحميل بدون useState إضافي.
الخطوة 13: عرض الملاحظات مع التحديث الفوري
أنشئ المكون الذي يعرض الملاحظات ويتحدث تلقائياً:
// 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("حذف هذه الملاحظة؟")) 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="mr-3 p-2 text-red-500 hover:bg-red-50
rounded-lg transition-colors"
aria-label="حذف"
>
{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 "الآن";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `منذ ${minutes} دقيقة`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `منذ ${hours} ساعة`;
const days = Math.floor(hours / 24);
return `منذ ${days} يوم`;
}
export function NotesList() {
const { notes, loading, error } = useNotes();
const [filter, setFilter] = useState<string>("الكل");
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 === "الكل"
? notes
: notes.filter((n) => n.category === filter);
return (
<div>
<div className="flex gap-2 mb-4 flex-wrap">
{["الكل", "شخصي", "عمل", "أفكار", "مهام", "أخرى"].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">
لا توجد ملاحظات حتى الآن. أنشئ ملاحظتك الأولى!
</p>
) : (
<div className="space-y-4">
{filteredNotes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
)}
</div>
);
}بفضل hook الـ useNotes الذي يستخدم onSnapshot، تتحدث القائمة فوراً عندما يضيف أو يحذف مستخدم آخر ملاحظة — بدون إعادة تحميل.
الخطوة 14: تجميع الصفحة الرئيسية
اجمع جميع المكونات في الصفحة الرئيسية:
// 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">
ملاحظات تعاونية
</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">
مرحباً بك في الملاحظات التعاونية
</h2>
<p className="text-gray-600 mb-8">
سجّل دخولك لإنشاء ومشاركة الملاحظات في الوقت الفعلي
</p>
<LoginButton />
</div>
) : (
<div className="grid gap-8 md:grid-cols-[1fr_350px]">
<section>
<h2 className="text-xl font-semibold mb-4">
جميع الملاحظات
</h2>
<NotesList />
</section>
<aside>
<NoteForm />
</aside>
</div>
)}
</div>
</main>
);
}الخطوة 15: إضافة middleware للمصادقة (اختياري)
لحماية مسارات معينة من جانب الخادم، أضف middleware:
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// التحقق من كعكة جلسة Firebase
const session = request.cookies.get("__session");
// المسارات المحمية
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*"],
};في تطبيق الملاحظات لدينا، تتم إدارة المصادقة من جانب العميل عبر سياق React. الـ middleware مفيد إذا أضفت صفحات محمية يتم عرضها من جانب الخادم.
الخطوة 16: تحسين الأداء
الترقيم مع Firestore
للتطبيقات التي تحتوي على العديد من الملاحظات، قم بتنفيذ الترقيم:
// 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);
// تحميل الصفحة الأولى
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 };
}فهارس Firestore
أنشئ ملف firestore.indexes.json لتحسين الاستعلامات:
{
"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" }
]
}
]
}انشر الفهارس باستخدام:
npx firebase deploy --only firestore:indexesالخطوة 17: النشر على Vercel
التحضير للنشر
- ادفع الكود إلى GitHub/GitLab
- اربط مستودعك بـ Vercel
- أضف متغيرات البيئة في إعدادات مشروع Vercel
# المتغيرات المطلوب إعدادها في 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إعداد النطاق المصرح
في وحدة تحكم Firebase، أضف نطاق Vercel إلى النطاقات المصرح بها:
- انتقل إلى Authentication > Settings > Authorized domains
- أضف
your-app.vercel.app - أضف نطاقك المخصص إن وجد
النشر
# باستخدام Vercel CLI
npm i -g vercel
vercel --prod
# أو ببساطة ادفع إلى فرع main للنشر التلقائي
git push origin mainتقدير تكاليف Firebase
تقدم Firebase طبقة مجانية سخية (خطة Spark):
| المورد | مجاني (Spark) | مدفوع (Blaze) |
|---|---|---|
| المصادقة | 50,000 مستخدم/شهر | 0.0055$/مستخدم |
| قراءات Firestore | 50,000/يوم | 0.06$/100,000 |
| كتابات Firestore | 20,000/يوم | 0.18$/100,000 |
| تخزين Firestore | 1 جيجابايت | 0.18$/جيجابايت |
| النطاق الترددي | 10 جيجابايت/شهر | 0.12$/جيجابايت |
لتطبيق ملاحظات مع بضع مئات من المستخدمين، ستكون الخطة المجانية أكثر من كافية.
استكشاف الأخطاء وإصلاحها
خطأ "auth/popup-blocked"
بعض المتصفحات تحظر النوافذ المنبثقة. الحل:
// البديل: استخدام signInWithRedirect بدلاً من signInWithPopup
import { signInWithRedirect } from "firebase/auth";
const signInWithGoogle = async () => {
await signInWithRedirect(auth, googleProvider);
};خطأ "Missing or insufficient permissions"
تحقق من:
- أن قواعد Firestore منشورة بشكل صحيح
- أن المستخدم مصادق عليه قبل الوصول إلى البيانات
- أن
userIdفي المستند يطابق UID Firebase
البيانات لا تتزامن فورياً
تحقق من:
- أنك تستخدم
onSnapshotوليسgetDocs - أن المستمع لم يتم إلغاء اشتراكه مبكراً (تحقق من تنظيف
useEffect) - أن قواعد Firestore تسمح بالقراءة
خطأ "FIREBASE_ADMIN_PRIVATE_KEY" في الإنتاج
المفتاح الخاص يحتوي على أحرف خاصة. تأكد من:
- إحاطة القيمة بعلامات اقتباس مزدوجة في
.env.local - تطبيق
replace(/\\n/g, "\n")في إعدادات Admin
المزيد من التطوير
إليك بعض الأفكار لتوسيع هذا المشروع:
- البحث النصي الكامل: دمج Algolia أو Typesense للبحث في الملاحظات
- مشاركة الملاحظات: إضافة صلاحيات دقيقة لكل ملاحظة
- وضع عدم الاتصال: تفعيل استمرارية Firestore للعمل بدون إنترنت
- إشعارات الدفع: استخدام Firebase Cloud Messaging للإبلاغ عن التحديثات
- تصدير PDF: السماح بتصدير الملاحظات بصيغة PDF
- محرر متقدم: استبدال textarea بـ TipTap أو Lexical
الخاتمة
في هذا الدرس، تعلمت كيفية بناء تطبيق full-stack كامل مع Firebase و Next.js 15. غطينا:
- المصادقة مع Firebase Auth و Google Sign-In
- تخزين البيانات مع Cloud Firestore
- المزامنة الفورية مع
onSnapshot - Server Actions لعمليات الكتابة الآمنة
- قواعد الأمان لحماية بياناتك
- الترقيم للتعامل مع كميات كبيرة من البيانات
- النشر على Vercel
Firebase مع Next.js 15 يقدمان stack قوي لإنشاء تطبيقات تفاعلية وتعاونية — بدون الحاجة لإعداد خادم خلفي أو قاعدة بيانات أو نظام مصادقة يدوياً. إنه خيار ممتاز للـ MVP والمشاريع الشخصية والتطبيقات الصغيرة والمتوسطة.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار
تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.

بناء تطبيق فوري مع Supabase و Next.js 15: الدليل الشامل
تعلّم كيفية بناء تطبيق full-stack فوري باستخدام Supabase و Next.js 15 App Router. يغطي هذا الدليل المصادقة وإعداد قاعدة البيانات و Row Level Security والاشتراكات الفورية.

Better Auth مع Next.js 15: الدليل الشامل للمصادقة في 2026
تعلم كيفية تنفيذ نظام مصادقة متكامل في Next.js 15 باستخدام Better Auth. يغطي هذا الدليل تسجيل الدخول بالبريد الإلكتروني وOAuth والجلسات وحماية المسارات والتحكم بالأدوار.