تظل 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 والمشاريع الشخصية والتطبيقات الصغيرة والمتوسطة.