المصادقة والمزيد
تسجيل الدخول
لإضافة المصادقة إلى تطبيق Next.js لدينا نستخدم NextAuth.js، وهي أشهر مكتبة مصادقة لـNext.js. تتولى NextAuth مجرى المصادقة بأكمله: الجلسات (sessions) وعمليات الاستدعاء (callbacks) وتكاملات المزوّدين. نحتاج أيضاً إلى bcryptjs لتجزئة كلمات المرور ومقارنتها بشكل آمن.
نبدأ بتثبيت الحزم المطلوبة:
npm install next-auth@beta bcryptjs && npm install -D @types/bcryptjs
تغيير المخطط
كما في الأجزاء السابقة من الدورة، نحتاج إلى تخزين كلمة مرور مُجزَّأة لكل مستخدم. يكتسب جدول users عموداً جديداً:
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: text("username").notNull().unique(),
name: text("name").notNull(),
passwordHash: text("password_hash").notNull().default(""),
})
يُسمى العمود passwordHash في TypeScript ويُقابل password_hash في قاعدة البيانات. نخزّن التجزئة فقط، ولا نخزّن كلمة المرور النصية الصريحة أبداً. القيمة الافتراضية المتمثلة في نص فارغ تتيح لنا إضافة العمود إلى جدول يحتوي بالفعل على صفوف دون خرق قيد not-null.
وكالعادة نولّد الترحيل ثم نطبّقه:
npx drizzle-kit generate
npx drizzle-kit migrate
إعدادات NextAuth
قلب الإعداد هو ملف إعدادات NextAuth auth.ts:
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { eq } from "drizzle-orm"
import bcrypt from "bcryptjs"
import { db } from "../db"
import { users } from "../db/schema"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null
}
const user = await db.query.users.findFirst({
where: eq(users.username, credentials.username as string),
})
if (!user || !user.passwordHash) {
return null
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash,
)
if (!isValid) {
return null
}
return {
id: String(user.id),
name: user.name,
email: user.username,
}
},
}),
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
})
تدعم NextAuth العديد من مزوّدي المصادقة المختلفين مثل Google وGitHub وFacebook. هنا نستخدم مزوّد بيانات الاعتماد (Credentials provider)، الذي يتيح للمستخدمين تسجيل الدخول باسم مستخدم وكلمة مرور.
تُعدّ دالة authorize جوهر مزوّد بيانات الاعتماد. تستقبل القيم التي كتبها المستخدم في نموذج تسجيل الدخول بوصفها credentials. ثم تبحث عن المستخدم في قاعدة البيانات باسم المستخدم، وتستخدم bcrypt.compare للتحقق مما إذا كانت كلمة المرور المُرسلة تطابق التجزئة المخزّنة. إذا نجحت المصادقة، تُعيد الدالة كائن مستخدم ستستخدمه NextAuth لبناء الجلسة. وإذا فشلت لأي سبب، تُعيد null، ما يجعل NextAuth يرفض محاولة تسجيل الدخول.
عند نجاح المصادقة، تُعيد authorize كائناً عادياً تشفّره NextAuth في رمز جلسة JWT:
{
id: String(user.id),
name: user.name,
email: user.username,
}
يحتوي نوع الجلسة المدمج في NextAuth على ثلاثة حقول: id و name و email. ليس لدينا بريد إلكتروني في مخططنا، لذا نعيد استخدام حقل email لتخزين اسم المستخدم بدلاً منه. هذا حلّ التفافي متعمّد: فلا تقدّم NextAuth حقل username مدمجاً، وبدلاً من توسيع نوع الجلسة بتعريفات TypeScript مخصّصة، نعيد استخدام حقل موجود يؤدي الغرض نفسه المتمثل في تحديد هوية المستخدم بشكل فريد. والنتيجة أننا حيثما قرأنا الجلسة لاحقاً على الخادم باستخدام auth()، نستخرج اسم المستخدم من session.user.email.
يخبر خيار pages: { signIn: "/login" } NextAuth باستخدام صفحة تسجيل الدخول الخاصة بنا على /login بدلاً من صفحة تسجيل الدخول المدمجة في NextAuth. وكلما احتاجت NextAuth إلى توجيه مستخدم غير مُصادَق إليه لتسجيل الدخول، أرسلته إلى /login. وبدون هذا الخيار، كانت NextAuth ستوجّهه إلى صفحتها الافتراضية على /api/auth/signin، وهي صالحة للعمل لكنها بلا تنسيق ومنفصلة عن بقية التطبيق.
يخبر خيار session: { strategy: "jwt" } NextAuth بتخزين بيانات الجلسة في JSON Web Token موقّع داخل ملف تعريف ارتباط (cookie)، بدلاً من مخزن جلسات على الخادم. وهذا يعمل جيداً في البيئات الخادمية بلا خوادم مثل Vercel حيث لا توجد ذاكرة مشتركة بين نسخ الدوال.
ما هي الجلسة في الحقيقة
بعد تسجيل دخول ناجح، تنشئ NextAuth جلسة وتخزّنها كـJWT موقّع في ملف تعريف ارتباط HTTP-only في المتصفح. ويُرسَل ملف تعريف الارتباط تلقائياً مع كل طلب لاحق. وعلى الخادم، تتحقق NextAuth من التوقيع وتقرأ الرمز لتعرف من هو المستخدم، دون لمس قاعدة البيانات.
يحتوي الرمز على ما أرجعته دالة authorize مهما كان: في حالتنا id و name و email (الذي استخدمناه لتخزين اسم المستخدم). هذه القيم متاحة عبر useSession على العميل وعبر auth() على الخادم. ولأن الرمز موقّع، فلا يمكن العبث به دون أن يكتشف الخادم ذلك.
مسار API للمصادقة
تتطلب NextAuth مسار API يتولى جميع طلبات المصادقة (تسجيل الدخول، تسجيل الخروج، فحوصات الجلسة). ننشئ الملف app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlers
جزء [...nextauth] هو مقطع مسار شامل (catch-all route segment) في Next.js يطابق أي مسار تحت /api/auth/، مثل /api/auth/signin و /api/auth/signout و /api/auth/session. تعترض NextAuth كل هذه المسارات وتتولى التعامل معها تلقائياً.
في الوقت الحالي، تعمل NextAuth هنا كصندوق أسود: نسلّمها إعداداتنا فتتولى كل نقاط نهاية المصادقة خلف الكواليس. اسم المجلد [...nextauth] ومفهوم مسارات API في Next.js جديدان علينا. سنعود إلى مسارات API لاحقاً في المادة.
مزوّد الجلسة والتخطيط
تحتاج بعض أجزاء واجهتنا إلى الوصول إلى الجلسة من جهة العميل. وشريط التنقل أوضح مثال على ذلك: إذ يمكنه مثلاً عرض اسم المستخدم المسجّل وزر تسجيل خروج عند وجود جلسة، ورابط تسجيل دخول عند عدم وجودها. يتطلب هذا النوع من العرض الشرطي أن تكون الجلسة متاحة في المتصفح، لا على الخادم فقط.
تشارك الخطافات الخاصة بالعميل في NextAuth مثل useSession بيانات الجلسة عبر سياق React (React context)، وهذا يتطلب وجود مكوّن مزوّد فوق أي مكوّن يستخدمه. وتوفّر NextAuth مزوّدها الخاص SessionProvider لهذا الغرض تحديداً. ولأنه يستخدم سياق React فيجب أن يكون مكوّن عميل، وبما أننا لا نستطيع وضع مكوّن عميل مباشرة في التخطيط الجذري، ننشئ غلافاً رفيعاً app/components/SessionProvider.tsx:
"use client"
import { SessionProvider } from "next-auth/react"
export default function AuthSessionProvider({
children,
}: {
children: React.ReactNode
}) {
return <SessionProvider>{children}</SessionProvider>
}
ثم نحدّث layout.tsx لنلفّ التطبيق داخل AuthSessionProvider، مما يمنح كل مكوّن عميل في الشجرة وصولاً إلى الجلسة عبر useSession:
import AuthSessionProvider from "./components/SessionProvider"
import NavBar from "./components/NavBar"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthSessionProvider>
<NavBar />
{children}
</AuthSessionProvider>
</body>
</html>
)
}
شريط التنقل
يحتاج شريط التنقل إلى العرض بشكل مختلف وفقاً لما إذا كان المستخدم مسجّلاً. ولأن زر تسجيل الخروج يستدعي signOut() داخل معالج onClick، فيجب أن يكون المكوّن مكوّن عميل. ويحصل على الجلسة عبر خطاف useSession، الذي يسحب القيمة من سياق React الذي يوفّره SessionProvider. نضع المكوّن في app/components/NavBar.tsx:
"use client"
import Link from "next/link"
import { useSession, signOut } from "next-auth/react"
export default function NavBar() {
const { data: session } = useSession()
return (
<nav>
<Link href="/">home</Link>
{" | "}
<Link href="/notes">notes</Link>
{" | "}
<Link href="/users">users</Link>
{" | "}
{session ? (
<>
<Link href="/notes/new">create new</Link>
{" | "}
<em>{session.user?.name} logged in</em>{" "}
<button onClick={() => signOut()}>logout</button>
</>
) : (
<Link href="/login">login</Link>
)}
</nav>
)
}
عند وجود جلسة، يعرض شريط التنقل اسم المستخدم المسجّل، ورابطاً لإنشاء ملاحظة جديدة، وزر تسجيل خروج يستدعي signOut. وعند عدم وجود جلسة، يعرض رابط تسجيل دخول بدلاً من ذلك.
صفحة تسجيل الدخول
تقع صفحة تسجيل الدخول في app/login/page.tsx. وهي مكوّن عميل لأنها تتولى إرسال النموذج وتدير حالة محلية لرسائل الخطأ:
"use client"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useState } from "react"
export default function LoginPage() {
const router = useRouter()
const [error, setError] = useState("")
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const result = await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
redirect: false,
})
if (result?.error) {
setError("Invalid username or password")
} else {
router.push("/")
router.refresh()
}
}
return (
<div>
<h2>Login</h2>
{error && <p style={{ color: "red" }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>
Username
<input type="text" name="username" required />
</label>
</div>
<div>
<label>
Password
<input type="password" name="password" required />
</label>
</div>
<button type="submit">Login</button>
</form>
</div>
)
}
يستدعي النموذج signIn من next-auth/react مع redirect: false، ما يعني أن NextAuth ستُعيد النتيجة ككائن بدلاً من إعادة توجيه المتصفح تلقائياً. وإذا فشلت المصادقة، فستُضبط قيمة result.error ونعرض رسالة خطأ. وإذا نجحت، نستخدم موجّه Next.js للانتقال إلى الصفحة الرئيسية ونستدعي router.refresh() لإجبار مكوّنات الخادم في الشجرة على إعادة العرض بالجلسة الجديدة.
قراءة الجلسة على الخادم
بالنسبة إلى مكوّنات الخادم وإجراءات الخادم لا يمكننا استخدام useSession، لأن الخطافات تعمل في المتصفح فقط. وبدلاً من ذلك توفّر Auth.js دالة auth، التي تقرأ الجلسة من ترويسات الطلب من جهة الخادم. نلفّ هذا في دالة مساعدة app/services/session.ts:
import { auth } from "@/auth"
import { eq } from "drizzle-orm"
import { db } from "../../db"
import { users } from "../../db/schema"
export const getCurrentUser = async () => {
const session = await auth()
if (!session?.user?.email) {
return null
}
return db.query.users.findFirst({
where: eq(users.username, session.user.email),
})
}
تذكّر أننا في دالة authorize في auth.ts خزّنّا اسم المستخدم في حقل email لكائن المستخدم المُعاد. وتستخرج دالة getCurrentUser تلك القيمة من الجلسة وتجلب سجل المستخدم الكامل من قاعدة البيانات.
حماية إجراء إنشاء الملاحظة
الآن وقد أصبحت المصادقة جاهزة، يمكننا حماية إجراء الخادم createNote بحيث لا يتمكن سوى المستخدمين المسجّلين من إنشاء الملاحظات:
export const createNote = async (formData: FormData) => {
const session = await auth()
if (!session) {
redirect("/login")
}
const content = formData.get("content") as string
const important = formData.get("important") === "on"
await addNote(content, important)
revalidatePath("/notes")
redirect("/notes")
}
إذا لم تكن هناك جلسة نشطة، يُوجَّه المستخدم إلى صفحة تسجيل الدخول قبل إنشاء أي ملاحظة.
يمكننا أيضاً استبدال الحلّ الالتفافي العشوائي للمستخدم في addNote باستدعاء سليم لـ getCurrentUser:
import { getCurrentUser } from "./session"
export const addNote = async (content: string, important: boolean) => {
const user = await getCurrentUser()
if (!user) {
throw new Error("Not logged in")
}
await db.insert(notes).values({ content, important, userId: user.id })
}
متغيرات البيئة
تتطلب NextAuth مفتاحاً سرياً لتوقيع رموز جلسة JWT. يمكنك توليد واحد مثلاً كما يلي:
echo "$(openssl rand -base64 32)"
انسخ الناتج يدوياً إلى .env.local:
DATABASE_URL=postgresql://...
AUTH_SECRET=your-generated-secret-here
على Vercel، أضف AUTH_SECRET و AUTH_URL تحت Settings > Environment Variables. وقيمة AUTH_URL هي الرابط العام لتطبيقك المنشور، مثلاً https://your-app.vercel.app.
تعيين كلمات المرور للمستخدمين الحاليين
بعد إضافة عمود passwordHash، أصبح لدى المستخدمين الحاليين في قاعدة البيانات نص فارغ كتجزئة لكلمة المرور، ما يعني أنهم لا يستطيعون تسجيل الدخول بعد. نحتاج إلى طريقة لتعيين كلمات مرور حقيقية لهم. وبدلاً من القيام بذلك يدوياً عبر Drizzle Studio، يمكننا كتابة نص أداة صغير set-password.ts في جذر المشروع:
import { config } from "dotenv"
config({ path: ".env.local" })
import bcrypt from "bcryptjs"
import { eq } from "drizzle-orm"
async function setPassword(username: string, password: string) {
const { db } = await import("./db")
const { users } = await import("./db/schema")
const hash = await bcrypt.hash(password, 10)
await db
.update(users)
.set({ passwordHash: hash })
.where(eq(users.username, username))
console.log(`Password set for user: ${username}`)
}
const username = process.argv[2]
const password = process.argv[3]
if (!username || !password) {
console.log("Usage: npx tsx set-password.ts <username> <password>")
process.exit(1)
}
setPassword(username, password).then(() => process.exit(0))
يقرأ النص اسم مستخدم وكلمة مرور نصية صريحة من وسائط سطر الأوامر. ويجزّئ كلمة المرور باستخدام bcrypt.hash بمعامل كلفة قدره 10، ثم يحدّث صف المستخدم المطابق في قاعدة البيانات. يتحكم معامل الكلفة في مقدار الكلفة الحسابية لحساب التجزئة: فالقيم الأعلى أبطأ في الكسر بالقوة الغاشمة لكنها أبطأ أيضاً في التحقق عند تسجيل الدخول. والقيمة 10 هي الافتراضية الموصى بها عادةً.
شغّل النص باستخدام tsx، الذي ينفّذ ملفات TypeScript مباشرة دون خطوة تجميع منفصلة:
npx tsx set-password.ts mluukkai secretpassword
هذا النص بطبيعة الحال أداة تطويرية فقط.
مراجعة مجرى المصادقة
توجد عدة أجزاء متحركة في إعداد المصادقة، لذا يجدر بنا التوقف قليلاً وتتبّع كيفية ارتباطها ببعضها.
تسجيل الدخول. يملأ المستخدم نموذج تسجيل الدخول على /login. ويستدعي معالج onSubmit في النموذج signIn("credentials", { redirect: false, ... })، وهي دالة توفّرها NextAuth. وتقوم signIn في NextAuth خلف الكواليس بإرسال طلب POST إلى مسار API الشامل عند /api/auth/callback/credentials. ومعالج المسار معرّف في app/api/auth/[...nextauth]/route.ts، حيث تسجّل handlers المصدَّرة من auth.ts معالجَي GET وPOST لدى Next.js.
عند وصول POST، يتولى منطق NextAuth الداخلي الأمر ويستدعي دالة authorize من auth.ts. تبحث تلك الدالة عن المستخدم في قاعدة البيانات باسم المستخدم وتستخدم bcrypt.compare للتحقق من كلمة المرور مقابل التجزئة المخزّنة. وإذا كانت بيانات الاعتماد صحيحة، تُعيد authorize كائن مستخدم يحتوي على id وname وemail (حيث نخزّن اسم المستخدم). تأخذ NextAuth ذلك الكائن وتشفّره في JWT موقّع بـAUTH_SECRET، وتضبطه كملف تعريف ارتباط HTTP-only في الاستجابة. لا يمكن لـJavaScript في المتصفح قراءة ملف تعريف ارتباط HTTP-only، بل يُرسَل تلقائياً مع كل طلب لاحق، ما يحمي الرمز من هجمات XSS. ثم تستخدم صفحة تسجيل الدخول router.push("/") لتوجيه المستخدم إلى الصفحة الرئيسية.
الجلسة من جهة العميل. في العرض التالي، يرسل المتصفح ملف تعريف الارتباط مع كل طلب. يقرأ AuthSessionProvider (الذي يلفّ التطبيق كله في التخطيط الجذري) ملف تعريف الارتباط، ويتحقق من JWT، ويجعل بيانات الجلسة متاحة عبر سياق React. وأي مكوّن عميل يستدعي useSession() يستقبل الجلسة من ذلك السياق دون أي طلب شبكة إضافي.
الجلسة من جهة الخادم. عندما يحتاج مكوّن خادم أو إجراء خادم إلى معرفة من هو المسجّل، يستدعي auth(). تقرأ NextAuth JWT الموقّع من ترويسات الطلب الواردة وتُعيد الجلسة المفكوكة. وتلفّ الدالة المساعدة getCurrentUser هذا الاستدعاء وتجلب إضافةً إلى ذلك سجل المستخدم الكامل من قاعدة البيانات بحيث تحصل بقية شيفرة التطبيق على كائن مستخدم سليم.
الإجراءات المحمية. تستدعي إجراءات الخادم التي تتطلب مصادقة الدالة auth() في بدايتها وتوجّه إلى /login إذا كانت الجلسة مفقودة. ولا تُنفَّذ أي كتابة في قاعدة البيانات إلا بعد التأكد من وجود جلسة صالحة.
يوضح مخطط التتابع أدناه عملية تسجيل الدخول وإنشاء ملاحظة محمية لاحقاً:

تسجيل المستخدمين
الآن وقد أصبحت المصادقة جاهزة، لننفّذ مجرى تسجيل سليماً بحيث يستطيع المستخدمون الجدد إنشاء حساباتهم بأنفسهم بدلاً من إضافتهم يدوياً عبر Drizzle Studio.
نحتاج إلى إجراء خادم يجزّئ كلمة المرور ويُدرج المستخدم الجديد في قاعدة البيانات. لنضفه إلى ملف جديد app/actions/users.ts:
"use server"
import { redirect } from "next/navigation"
import bcrypt from "bcryptjs"
import { db } from "../../db"
import { users } from "../../db/schema"
export const registerUser = async (formData: FormData) => {
const username = (formData.get("username") as string)?.trim()
const name = (formData.get("name") as string)?.trim()
const password = formData.get("password") as string
const passwordHash = await bcrypt.hash(password, 10)
await db.insert(users).values({ username, name, passwordHash })
redirect("/login")
}
يقرأ الإجراء username و name و password من بيانات النموذج، ويجزّئ كلمة المرور بـbcrypt.hash، ويُدرج المستخدم الجديد، ويوجّه إلى صفحة تسجيل الدخول.
بما أن الإجراء إجراء خادم عادي يوجّه دائماً عند النجاح، يمكن أن تكون صفحة التسجيل مكوّن خادم بسيطاً مع نموذج يشير مباشرة إلى الإجراء:
import Link from "next/link"
import { registerUser } from "../actions/users"
export default function RegisterPage() {
return (
<div>
<h2>Register</h2>
<form action={registerUser}>
<div>
<label>
Username
<input type="text" name="username" required />
</label>
</div>
<div>
<label>
Name
<input type="text" name="name" required />
</label>
</div>
<div>
<label>
Password
<input type="password" name="password" required />
</label>
</div>
<button type="submit">Register</button>
</form>
</div>
)
}
أخيراً، أضف رابطاً إلى صفحة التسجيل في NavBar.tsx ليتمكن المستخدمون غير المُصادَقين من العثور عليها:
"use client"
import Link from "next/link"
import { useSession, signOut } from "next-auth/react"
export default function NavBar() {
const { data: session } = useSession()
return (
<nav>
<Link href="/">home</Link>
{" | "}
<Link href="/notes">notes</Link>
{" | "}
<Link href="/users">users</Link>
{" | "}
{session ? (
<>
<Link href="/notes/new">create new</Link>
{" | "}
<em>{session.user?.name} logged in</em>{" "}
<button onClick={() => signOut()}>logout</button>
</>
) : (
<>
<Link href="/login">login</Link>
{" | "}
<Link href="/register">register</Link>
</>
)}
</nav>
)
}
الآن يمكن للمستخدمين الجدد التسجيل عبر النموذج، ولم تعد هناك حاجة لتعيين كلمات المرور يدوياً بنص set-password.ts.
الشيفرة الحالية للتطبيق موجودة في GitHub في الفرع part8.
الاسم المستعار للمسار @
ربما لاحظت أن الاستيراد بدا هكذا:
import { handlers } from "@/auth"
export const { GET, POST } = handlers
البادئة @/ هي اسم مستعار للمسار يُقابل جذر مشروعك. فبدلاً من كتابة مسار نسبي مثل ../../../auth، يمكنك دائماً كتابة @/auth بغض النظر عن عمق تداخل الملف المستورِد. وهذا يجعل الاستيرادات أسهل قراءة وإعادة هيكلة.
الاسم المستعار معرّف في tsconfig.json تحت compilerOptions:
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}
تتضمن مشاريع Next.js الجديدة المنشأة بـcreate-next-app هذا الإعداد افتراضياً. وإذا لم يكن مشروعك يحتويه، فأضف مدخل paths أعلاه وأعد تشغيل خادم لغة TypeScript. ثم يمكنك تنظيف كل سلاسل الاستيراد النسبي في أنحاء الشيفرة.
على سبيل المثال، في الملف app/actions/users.ts بدلاً من:
import { db } from "../../db"
import { users } from "../../db/schema"
يمكنك كتابة:
import { db } from "@/db"
import { users } from "@/db/schema"
يشير كلاهما إلى الملفات نفسها، لكن نسخ @/ لا لبس فيها دائماً ولا تتعطل عند نقل ملف إلى مجلد مختلف.
11. تسجيل الدخول
12. التسجيل
معالجة الأخطاء
لنضف بعض التحقق من المدخلات إلى نموذج إنشاء الملاحظة. نريد فرض حد أدنى لطول محتوى الملاحظة قدره 10 أحرف.
التحقق من جهة العميل
أبسط نهج هو استخدام التحقق المدمج في HTML في المتصفح. إضافة minLength={10} إلى عنصر الإدخال تمنع إرسال النموذج أصلاً إذا كان المحتوى قصيراً جداً:
import { createNote } from "../../actions/notes"
const NewNote = () => {
return (
<div>
<h2>Create a new note</h2>
<form action={createNote}>
<div>
<label>
Content
<input type="text" name="content" required minLength={10} />
</label>
</div>
<div>
<label>
<input type="checkbox" name="important" />
Important
</label>
</div>
<button type="submit">Create</button>
</form>
</div>
)
}
هذا سريع ولا يتطلب شيفرة إضافية، لكنه يعمل في المتصفح فقط. إذ يمكن للمستخدم تجاوزه تماماً بإرسال طلب HTTP مباشرة إلى نقطة نهاية إجراء الخادم. لذا ينبغي دائماً دعم التحقق من جهة العميل بتحقق من جهة الخادم أيضاً.
أبسط نهج من جهة الخادم هو فحص المحتوى في إجراء الخادم ورمي خطأ إذا كان غير صالح:
export const createNote = async (formData: FormData) => {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
const content = formData.get("content") as string
if (!content || content.length < 10) {
throw new Error("Note content must be at least 10 characters long")
}
const important = formData.get("important") === "on"
await addNote(content, important)
revalidatePath("/notes")
redirect("/notes")
}
رمي خطأ من إجراء خادم يجعل Next.js يعرض أقرب حدود خطأ (error boundary). هذا مناسب للإخفاقات غير المتوقعة، لكنه بالنسبة إلى أخطاء التحقق الموجّهة للمستخدم تجربة سيئة: إذ يرى المستخدم صفحة خطأ بدلاً من رسالة مفيدة بجانب النموذج.
إعادة أخطاء التحقق باستخدام useActionState
نمط أفضل للتحقق من النماذج هو إعادة الخطأ من إجراء الخادم بدلاً من رميه، ثم عرض الرسالة داخل النموذج. ويوفّر React 19 خطاف useActionState لهذا الغرض تحديداً.
يحتاج إجراء الخادم إلى تغيير توقيعه. فبدلاً من استقبال formData فقط، يستقبل الآن أيضاً prevState كوسيطه الأول. هذه هي الحالة السابقة التي أرجعها الإجراء (أو القيمة الأولية في العرض الأول). ولا يستخدم الإجراء prevState فعلياً هنا، بل يحتاج فقط إلى قبوله حتى يطابق التوقيع ما يتوقعه useActionState.
عند فشل التحقق، يُعيد كائن حالة جديداً يحتوي على رسالة خطأ. وعند النجاح، يستدعي redirect كما كان من قبل:
export const createNote = async (
prevState: { error: string },
formData: FormData,
) => {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
const content = formData.get("content") as string
if (!content || content.length < 10) {
return { error: "Note content must be at least 10 characters long" }
}
const important = formData.get("important") === "on"
await addNote(content, important)
revalidatePath("/notes")
redirect("/notes")
}
من جهة النموذج، يصبح المكوّن مكوّن عميل ليتمكن من استخدام خطاف useActionState. يأخذ الخطاف دالة الإجراء وقيمة حالة أولية، ويُعيد الحالة الحالية وإجراءً ملفوفاً لتمريره إلى النموذج:
"use client"
import { useActionState } from "react"
import { createNote } from "../../actions/notes"
const NewNote = () => {
const [state, formAction] = useActionState(createNote, { error: "" })
return (
<div>
<h2>Create a new note</h2>
<form action={formAction}>
<div>
<label>
Content
<input type="text" name="content" />
</label>
</div>
<div>
<label>
<input type="checkbox" name="important" />
Important
</label>
</div>
<button type="submit">Create</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
</form>
</div>
)
}
عندما يرسل المستخدم النموذج بمحتوى قصير جداً، يُعيد إجراء الخادم { error: "..." }. ويخزّن useActionState ذلك بوصفه state الجديد، ويُعاد عرض المكوّن مع إظهار رسالة الخطأ أسفل زر الإرسال. ولا يحدث أي انتقال بين الصفحات. وعندما يكون المحتوى صالحاً، يستدعي الإجراء redirect("/notes") كما كان من قبل ويُوجَّه المستخدم إلى قائمة الملاحظات.
الشيفرة الحالية للتطبيق موجودة في GitHub في الفرع part9.
13. التحققات في إنشاء المدونة
14. نموذج إنشاء المدونة عند الخطأ
15. التحققات في تسجيل المستخدم
مزيد من مكوّنات الواجهة: الإشعار
لنضف نظام إشعارات لتقديم تغذية راجعة للمستخدم بعد إجراءات مثل إنشاء ملاحظة. نريد عرض رسالة قصيرة، مثلاً «تم إنشاء الملاحظة»، تختفي تلقائياً بعد بضع ثوانٍ. هذه حالة استخدام كلاسيكية لـسياق React، الذي يتيح لنا مشاركة الحالة عبر شجرة المكوّنات دون تمرير props في كل مستوى. إذا لم يكن سياق React مألوفاً لديك، فألقِ نظرة على الجزء 6 من دورة Full Stack Open قبل المتابعة.
ننشئ ملفين: app/components/NotificationContext.tsx يحتوي على تعريف السياق والمزوّد، وapp/components/Notification.tsx هو المكوّن الذي يعرض الرسالة المرئية.
يعرّف ملف السياق NotificationContext.tsx شكل حالة الإشعار ويكشف كلاً من غلاف NotificationProvider وخطاف useNotification:
"use client"
import { createContext, useContext, useState } from "react"
type NotificationType = "success" | "error"
type NotificationContextType = {
message: string
type: NotificationType
showNotification: (message: string, type?: NotificationType) => void
}
const NotificationContext = createContext<NotificationContextType>({
message: "",
type: "success",
showNotification: () => {},
})
export const NotificationProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [message, setMessage] = useState("")
const [type, setType] = useState<NotificationType>("success")
const showNotification = (
msg: string,
notifType: NotificationType = "success",
) => {
setMessage(msg)
setType(notifType)
setTimeout(() => setMessage(""), 5000)
}
return (
<NotificationContext value={{ message, type, showNotification }}>
{children}
</NotificationContext>
)
}
export const useNotification = () => useContext(NotificationContext)
يحتوي السياق على message وtype كحالة. وتضبط دالة showNotification الرسالة وتجدول setTimeout لمسحها بعد 5 ثوانٍ.
تصف واجهة NotificationContextType شكل قيمة السياق: نص message الحالي، وtype الذي يتحكم في لون الشريط، ودالة showNotification. وتمرير هذا النوع كوسيط عام (generic) إلى createContext<NotificationContextType>(...) يخبر TypeScript بدقة ما الشكل الذي يحمله السياق. والوسيط الثاني هو القيمة الافتراضية المستخدمة عندما يستدعي مكوّن useNotification خارج NotificationProvider. ولأننا نلفّ التطبيق كله دائماً في NotificationProvider، فلا تُستخدم هذه القيمة الافتراضية عملياً أبداً، لكن TypeScript يتطلبها، ويجب أن تطابق النوع المعلن.
يقرأ مكوّن Notification من السياق ويعرض الرسالة:
"use client"
import { useNotification } from "./NotificationContext"
export default function Notification() {
const { message, type } = useNotification()
if (!message) return null
const style: React.CSSProperties = {
padding: "10px 16px",
marginBottom: "10px",
borderRadius: "4px",
color: "white",
backgroundColor: type === "success" ? "#16a34a" : "#dc2626",
}
return <div style={style}>{message}</div>
}
عندما تكون message فارغة يُعيد المكوّن null ولا يعرض شيئاً. وعندما تكون هناك رسالة، يعرض شريطاً ملوّناً: أخضر لـ"success" وأحمر لـ"error".
لجعل الإشعار متاحاً في التطبيق كله نلفّ التخطيط بـNotificationProvider ونضع Notification أسفل شريط التنقل مباشرةً، بحيث يظهر في أعلى كل صفحة. ويبدو app/layout.tsx المحدَّث هكذا:
import AuthSessionProvider from "./components/SessionProvider"
import NavBar from "./components/NavBar"
import { NotificationProvider } from "./components/NotificationContext"
import Notification from "./components/Notification"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<AuthSessionProvider>
<NotificationProvider>
<NavBar />
<Notification />
{children}
</NotificationProvider>
</AuthSessionProvider>
</body>
</html>
)
}
يبقى AuthSessionProvider الغلاف الخارجي الأقصى لأن سياق SessionProvider في NextAuth يجب أن يكون أيضاً مكوّن عميل. ويقع NotificationProvider داخله، فيكون السياقان متاحين لجميع مكوّنات العميل في الشجرة.
عرض إشعار بعد إنشاء الملاحظة
يستدعي مجرى إنشاء الملاحظة الحالي redirect("/notes") داخل إجراء الخادم، ما يعني أنه لا يوجد مكان طبيعي لإطلاق إشعار من جهة العميل قبل حدوث الانتقال. نحتاج إلى تغيير النهج قليلاً: فبدلاً من التوجيه داخل الإجراء، نُعيد علامة success وندع مكوّن العميل يتولى الإشعار والتوجيه معاً.
يُعيد إجراء createNote المحدَّث في app/actions/notes.ts كائن حالة بدلاً من التوجيه عند النجاح:
export const createNote = async (
prevState: { error: string; success?: boolean },
formData: FormData,
) => {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
const content = formData.get("content") as string
if (!content || content.length < 10) {
return {
error: "Note content must be at least 10 characters long",
success: false,
}
}
const important = formData.get("important") === "on"
await addNote(content, important)
revalidatePath("/notes")
return { error: "", success: true }
}
عند فشل التحقق يُعيد الإجراء { error: "...", success: false }. وعند النجاح يستدعي revalidatePath لإبطال قائمة الملاحظات المخزّنة مؤقتاً ويُعيد { error: "", success: true }. ولم يعد التوجيه موجوداً في الإجراء.
يستخدم مكوّن NewNote الآن useEffect لمراقبة علامة success في حالة النموذج. وعندما تصبح true، يستدعي showNotification ثم ينتقل برمجياً باستخدام router.push:
"use client"
import { useActionState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { createNote } from "../../actions/notes"
import { useNotification } from "../../components/NotificationContext"
const NewNote = () => {
const [state, formAction] = useActionState(createNote, {
error: "",
success: false,
})
const { showNotification } = useNotification()
const router = useRouter()
useEffect(() => {
if (state.success) {
showNotification("note created")
router.push("/notes")
}
}, [state, showNotification, router])
return (
<div>
<h2>Create a new note</h2>
<form action={formAction}>
<div>
<label>
Content
<input type="text" name="content" />
</label>
</div>
<div>
<label>
<input type="checkbox" name="important" />
Important
</label>
</div>
<button type="submit">Create</button>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
</form>
</div>
)
}
export default NewNote
يعمل خطاف useEffect بعد كل عرض تتغير فيه state. وعندما تكون state.success بقيمة true، يُطلق الإشعار والانتقال. ولأن showNotification تخزّن الرسالة في السياق، فسيلتقطها مكوّن Notification المعروض في layout.tsx ويعرض الشريط الأخضر في صفحة /notes بعد التوجيه.
الشيفرة الحالية للتطبيق موجودة في GitHub في الفرع part10.
أين نضع المكوّنات
أصبح في تطبيقنا الآن مكوّنات موزّعة على عدة مجلدات. ويجدر بنا التوقف لشرح الأعراف التي نتبعها.
مجلد app/components/ مخصّص لمكوّنات الواجهة المشتركة المستخدمة في أكثر من مكان في التطبيق، أو التي تنتمي إلى التخطيط العام بدلاً من أي صفحة محدّدة. وتنتمي NavBar وNotification وNotificationContext كلها إلى هنا: فهي تُعرض من التخطيط الجذري وتستهلكها صفحات وإجراءات مختلفة كثيرة.
المكوّنات التي تنتمي إلى صفحة واحدة تعيش بجانب تلك الصفحة في مجلدها الخاص. على سبيل المثال، مكوّن NoteList الذي كان لدينا في فصل عرض الملاحظات المهمة فقط، حل مكوّن العميل لا يُعرض إلا في صفحة قائمة الملاحظات، لذا سيُوضع في app/notes/ لا في app/components/. وهذا يبقي كل ميزة مكتفية بذاتها: يمكنك النظر إلى مجلد الصفحة ورؤية كل الأجزاء التي بُنيت منها فوراً.
كقاعدة عامة: إذا كان المكوّن يُستورد من أكثر من مسار، فانقله إلى app/components/. وإذا كان يُستخدم في مسار واحد فقط، فأبقِه في مجلد ذلك المسار.
تنسيق التطبيق بـTailwind CSS
حتى الآن استخدمنا كائنات style المضمّنة في الحالات القليلة التي احتجنا فيها إلى أي تنسيق. أما التطبيق الحقيقي فيحتاج إلى نهج سليم لـCSS. والخيار السائد في منظومة Next.js هو Tailwind CSS، إطار CSS قائم على الأدوات المساعدة (utility-first).
ملاحظة حول مكتبات المكوّنات: قد تتساءل لماذا لا نستخدم مكتبة مكوّنات مثل Material-UI التي استخدمناها في أجزاء سابقة من الدورة. فرغم أنه يمكن استخدام هذه المكتبات مع Next.js، إلا أنها تتطلب إعدادات إضافية لتعمل بشكل صحيح مع مكوّنات الخادم والموجّه App Router. وتعتمد معظم مكتبات المكوّنات اعتماداً كبيراً على JavaScript في العميل وتحتاج إلى إعداد خاص للعرض من جهة الخادم والترطيب (hydration). ويتوافق نهج Tailwind القائم على الأدوات المساعدة بشكل أفضل مع معمارية Next.js التي تعطي الأولوية للخادم، ويعمل بسلاسة مع مكوّنات الخادم والعميل معاً دون إعداد إضافي.
فكرة Tailwind مختلفة عن أطر CSS التقليدية مثل MaterialUI أو Bootstrap. فبدلاً من توفير مكوّنات جاهزة بتنسيقات ثابتة، يمنحك Tailwind مجموعة كبيرة من أصناف الأدوات المساعدة الصغيرة أحادية الغرض، مثل flex و p-4 و text-gray-800 و rounded، تركّبها مباشرة في الترميز. فلا يوجد ملف CSS منفصل للصيانة ولا خطر لتسرّب تنسيقات مكوّن إلى آخر.
يمكن لمشاريع Next.js الجديدة المنشأة بـcreate-next-app أن تتضمن Tailwind تلقائياً. وإذا كنت تضيفه إلى مشروع موجود، فاتبع دليل التثبيت في وثائق Tailwind.
مع وجود Tailwind، يتم التنسيق بإضافة أسماء الأصناف إلى عناصر JSX. ويمكن إعطاء body في app/layout.tsx لون خلفية ولون نص:
import "./globals.css"
import AuthSessionProvider from "./components/SessionProvider"
import NavBar from "./components/NavBar"
import { NotificationProvider } from "./components/NotificationContext"
import Notification from "./components/Notification"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="min-h-screen bg-background text-foreground">
<AuthSessionProvider>
<NotificationProvider>
<NavBar />
<Notification />
<main>{children}</main>
</NotificationProvider>
</AuthSessionProvider>
</body>
</html>
)
}
تضمن min-h-screen أن يمتد body ليملأ ارتفاع نافذة العرض بالكامل، بينما يطبّق bg-background و text-foreground ألوان الخلفية والنص الافتراضية للسمة كخصائص CSS مخصّصة معرّفة في globals.css. والملف منشأ بواسطة create-next-app ويبدو هكذا:
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
يحمّل سطر @import "tailwindcss" جميع أدوات Tailwind المساعدة. ومتغيرا CSS --background و--foreground معرّفان في :root ويتحولان تلقائياً إلى قيم الوضع الداكن عندما يفضّل نظام تشغيل المستخدم مخطط ألوان داكناً. وتقوم كتلة @theme inline بتعيين هذين المتغيرين في لوحة ألوان Tailwind بحيث تُحلّ أصناف مثل bg-background وtext-foreground إلى القيم الصحيحة.
تحصل صفحة قائمة الملاحظات على تخطيط مقيّد وموسّط مع تباعد بين عناصر القائمة:
import Link from "next/link"
import { getNotes } from "../services/notes"
const Notes = async ({
searchParams,
}: {
searchParams: Promise<{ important?: string }>
}) => {
const { important } = await searchParams
const showImportant = important === "true"
const notes = await getNotes(showImportant)
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Notes</h2>
<div className="mb-4">
<Link
href={showImportant ? "/notes" : "/notes?important=true"}
className="text-blue-600 hover:underline"
>
{showImportant ? "show all" : "show important only"}
</Link>
</div>
<ul className="space-y-2">
{notes.map((note) => (
<li key={note.id} className="border rounded p-3 hover:bg-gray-50">
<Link
href={`/notes/${note.id}`}
className="text-blue-600 hover:underline"
>
{note.content}
</Link>
{note.important && (
<strong className="ml-2 text-amber-600">(important)</strong>
)}
</li>
))}
</ul>
</div>
)
}
export default Notes
تقيّد max-w-2xl عرض المحتوى، ويوسّطه mx-auto بهوامش أفقية تلقائية، ويضيف p-6 حشوة داخلية. ويجعل text-2xl و font-bold العنوان كبيراً وعريضاً، بينما يضيف mb-4 تباعداً أسفله. ويضيف space-y-2 تباعداً عمودياً بين عناصر القائمة، ويمنح border rounded p-3 hover:bg-gray-50 كل ملاحظة مظهر بطاقة ناعمة مع إبراز عند مرور المؤشر.
ويصبح شريط التنقل شريطاً أفقياً داكناً بروابط متباعدة:
"use client"
import Link from "next/link"
import { useSession, signOut } from "next-auth/react"
const NavBar = () => {
const { data: session } = useSession()
return (
<nav className="bg-gray-800 text-white px-6 py-3 flex items-center gap-4">
<Link href="/" className="hover:text-gray-300">
home
</Link>
<Link href="/notes" className="hover:text-gray-300">
notes
</Link>
<Link href="/users" className="hover:text-gray-300">
users
</Link>
<div className="ml-auto flex items-center gap-4">
{session ? (
<>
<Link href="/notes/new" className="hover:text-gray-300">
create new
</Link>
<em className="text-gray-300">{session.user?.name} logged in</em>
<button
onClick={() => signOut()}
className="bg-gray-600 hover:bg-gray-500 px-3 py-1 rounded text-sm"
>
logout
</button>
</>
) : (
<>
<Link href="/login" className="hover:text-gray-300">
login
</Link>
<Link href="/register" className="hover:text-gray-300">
register
</Link>
</>
)}
</div>
</nav>
)
}
export default NavBar
تُقرأ أسماء الأصناف تقريباً كنص عادي: bg-gray-800 يضبط خلفية داكنة، وtext-white يجعل النص أبيض، وpx-6 و py-3 يضيفان حشوة أفقية وعمودية، ويضع flex items-center gap-4 الأبناء في صف مع تباعد، ويدفع ml-auto القسم المعتمد على الجلسة إلى الحافة اليمنى. وتحتوي وثائق Tailwind على مرجع قابل للبحث لكل صنف من أصناف الأدوات المساعدة.
قد تلاحظ أن نص className="hover:text-gray-300" نفسه يظهر على كل رابط تنقل. وعندما تتكرر تركيبة أصناف Tailwind مرات كثيرة، فقد تكون فكرة جيدة استخراج مكوّن مساعد صغير.
مكوّن NavLink محفوظ في app/components/NavLink.tsx:
import Link from "next/link"
interface NavLinkProps {
href: string
children: React.ReactNode
}
const NavLink = ({ href, children }: NavLinkProps) => {
return (
<Link href={href} className="hover:text-gray-300">
{children}
</Link>
)
}
export default NavLink
مع وجود NavLink، يصبح كل عنصر تنقل في NavBar على شكل <NavLink href="...">label</NavLink> بدلاً من <Link> مع className متكرر. ويحتفظ زر تسجيل الخروج المعتمد على الجلسة بتنسيقه المضمّن الخاص لأنه ذو معالجة بصرية مختلفة وغير متكرر في مكان آخر:
"use client"
import { useSession, signOut } from "next-auth/react"
import NavLink from "./NavLink"
export default function NavBar() {
const { data: session } = useSession()
return (
<nav className="bg-gray-800 text-white px-6 py-3 flex items-center gap-4">
<NavLink href="/">home</NavLink>
<NavLink href="/notes">notes</NavLink>
<NavLink href="/users">users</NavLink>
<div className="ml-auto flex items-center gap-4">
{session ? (
<>
<NavLink href="/notes/new">create new</NavLink>
<em className="text-gray-300">{session.user?.name} logged in</em>
<button
onClick={() => signOut()}
className="bg-gray-600 hover:bg-gray-500 px-3 py-1 rounded text-sm"
>
logout
</button>
</>
) : (
<>
<NavLink href="/login">login</NavLink>
<NavLink href="/register">register</NavLink>
</>
)}
</div>
</nav>
)
}
هناك نهج آخر لتحقيق الأمر نفسه وهو استخدام توجيه @layer components في ملف CSS لديك. يتيح لك ذلك تعريف أصناف CSS مخصّصة تجمع عدة أدوات مساعدة من Tailwind، دون إنشاء مكوّن React منفصل. وكلا النهجين يعمل جيداً: فاستخراج مكوّن React يُبقي منطق التنسيق في المكان نفسه الذي توجد فيه بنية المكوّن، بينما يُبقي نهج طبقة CSS جميع تعريفات التنسيق معاً في ملف CSS واحد.
الشيفرة الحالية للتطبيق موجودة في GitHub في الفرع part11.
تعلّم Tailwind وتطبيقه
أفضل طريقة للاعتياد على Tailwind هي استخدامه عملياً وإبقاء الوثائق مفتوحة في علامة تبويب في المتصفح. فتحتوي الوثائق على شريط بحث يعثر على أي صنف من أصناف الأدوات المساعدة بالكلمة المفتاحية. اكتب "padding" فتحصل على القائمة الكاملة لأصناف p- و px- و py- و pt- وقيمها. واكتب "flex" فترى كل ما يتعلق بتخطيط flexbox. وبعد وقت قصير تصبح أكثر الأصناف شيوعاً راسخة في الذاكرة.
توجد أيضاً دروس فيديو جيدة على YouTube، مثلاً Master Tailwind in One Hour و Tailwind CSS in React Crash Course 2026، وهما مقدّمتان سريعتان لكن شاملتان إلى المفاهيم والأدوات الأساسية في Tailwind.
يُعدّ Tailwind Playground أداة ممتازة لتجربة أصناف Tailwind في المتصفح دون أي إعداد. يمكنك كتابة HTML بأصناف Tailwind ورؤية النتائج فوراً، ما يجعله مثالياً لتجربة تركيبات أو تعلّم كيفية عمل الأدوات المختلفة معاً.
بضع نصائح عملية:
- ابدأ بالتخطيط أولاً. اضبط الهياكل الخارجية باستخدام flex و grid و max-w- وأدوات التباعد قبل الاهتمام بالألوان أو الطباعة.
- استخدم إضافة Tailwind CSS IntelliSense في VS Code. فهي تكمل أسماء الأصناف تلقائياً، وتُظهر CSS الكامن عند مرور المؤشر، وتبرز الأصناف غير المعروفة.
- عندما تجد نفسك تكرّر المجموعة نفسها من الأصناف على عناصر كثيرة، فهذه إشارة إلى استخراج مكوّن صغير، كما فعلنا مع NavLink.
- يُعدّ Tailwind Cheat Sheet مرجعاً سريعاً مفيداً يجمع كل الأصناف الشائعة في صفحة واحدة.
بمجرد أن تعتاد على Tailwind للتخطيط والتباعد، سترغب على الأرجح في مكوّنات تفاعلية جاهزة مثل النوافذ الحوارية والقوائم المنسدلة ومنتقيات التاريخ. ويتطلب بناؤها من الصفر بـTailwind الخام جهداً كبيراً. وهنا يأتي دور shadcn/ui: فهو مجموعة من المكوّنات المصقولة وسهلة الوصول المبنية فوق Tailwind وأساسيات Radix UI. وخلافاً لمعظم مكتبات المكوّنات، لا تُوزَّع shadcn/ui كحزمة npm. بل تنسخ المكوّنات فرادى مباشرة إلى مشروعك بأمر CLI، فتعيش الشيفرة في مستودعك ويمكنك تعديلها بحرية. سنلقي نظرة على shadcn/ui بمزيد من التفصيل في القسم التالي.
التمارين
16. إشعار منسّق باستخدام السياق
17. مزيد من التنسيق
المزيد عن Drizzle: واجهة استعلامات SQL
استخدمنا في هذه المادة واجهة الاستعلامات العلائقية في Drizzle: استدعاءات مثل db.query.notes.findMany() و db.query.users.findFirst({ with: { notes: true } }). هذه الواجهة مريحة لأنها تتولى عمليات الربط (joins) تلقائياً استناداً إلى تعريفات العلاقات في schema.ts وتُعيد كائنات متداخلة ومحدّدة النوع.
توفّر Drizzle أيضاً منشئ استعلامات SQL الأدنى مستوى الذي يقابل صيغة SQL بشكل أكثر مباشرة. ويبدو الاستعلام نفسه الذي يجلب كل الملاحظات المهمة هكذا مع واجهة SQL:
import { eq } from "drizzle-orm"
import { db } from "../../db"
import { notes } from "../../db/schema"
const importantNotes = await db
.select()
.from(notes)
.where(eq(notes.important, true))
وجلب مستخدم مع ملاحظاته باستخدام ربط صريح:
import { eq } from "drizzle-orm"
import { db } from "../../db"
import { users, notes } from "../../db/schema"
const result = await db
.select()
.from(users)
.leftJoin(notes, eq(notes.userId, users.id))
.where(eq(users.id, 1))
يمنحك هذا مصفوفة مسطّحة من الصفوف (صف لكل ملاحظة مربوطة)، ثم تجمّعها بنفسك، بخلاف الواجهة العلائقية التي تمنحك كائناً متداخلاً.
تكون واجهة SQL مفيدة عندما تحتاج إلى تحكم دقيق في شكل الاستعلام، أو تريد استخدام ميزات SQL لا تكشفها الواجهة العلائقية، أو تكتب شيئاً معقداً بما يكفي ليكون الربط الصريح أوضح. أما لعمليات CRUD اليومية، فالواجهة العلائقية أكثر ملاءمة. والواجهتان محدّدتا النوع بالكامل ويمكن مزجهما بحرية في المشروع نفسه.
مسارات API
حتى الآن جلبنا البيانات وعدّلناها باستخدام مكوّنات الخادم وإجراءات الخادم. ويدعم Next.js أيضاً مسارات API التقليدية، وتسمى أيضاً معالجات المسارات (Route Handlers)، والتي تتيح لك كشف نقاط نهاية HTTP تُعيد استجابات اعتباطية، عادةً JSON.
استخدمنا بالفعل مسار API واحداً دون أن نوليَه اهتماماً كبيراً: معالج NextAuth الشامل في app/api/auth/[...nextauth]/route.ts. يصدّر ذلك الملف دالة معالج لكل من GET وPOST وتعترض NextAuth تلقائياً كل طلب تحت /api/auth/. تعاملنا معه حينها كصندوق أسود، لكن الملف ليس سوى معالج مسار عادي في Next.js.
كيف تعمل معالجات المسارات
معالج المسار هو ملف اسمه route.ts (أو route.js) داخل مجلد app. يصدّر دوال غير متزامنة مسمّاة تقابل طرق HTTP: GET و POST و PUT و PATCH و DELETE وهكذا.
لنضف إلى تطبيقنا نقطة نهاية بسيطة للقراءة فقط تُعيد قائمة الملاحظات بصيغة JSON. ننشئ الملف app/api/notes/route.ts:
import { NextResponse } from "next/server"
import { getNotes } from "../../services/notes"
export const GET = async () => {
const notes = await getNotes(false)
return NextResponse.json(notes)
}
تؤدي زيارة /api/notes في المتصفح (أو بـcurl) الآن إلى إرجاع مصفوفة الملاحظات بصيغة JSON:
[
{ "id": 1, "content": "next.js utilizes React Server Components", "important": true, "userId": 1 },
{ "id": 2, "content": "next.js is built on top of React", "important": true, "userId": 1 }
]
قد تبدو نقطة النهاية التي تتيح إنشاء ملاحظة عبر POST هكذا:
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "../../../lib/auth"
import { addNote } from "../../services/notes"
import { revalidatePath } from "next/cache"
export const POST = async (req: NextRequest) => {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await req.json()
const { content, important = false } = body
if (!content || content.length < 10) {
return NextResponse.json(
{ error: "Content must be at least 10 characters" },
{ status: 400 },
)
}
await addNote(content, important)
revalidatePath("/notes")
return NextResponse.json({ success: true }, { status: 201 })
}
يقرأ معالج POST جسم الطلب بصيغة JSON باستخدام req.json()، ويتحقق من المحتوى، ويستدعي دالة الخدمة addNote نفسها التي يستخدمها إجراء الخادم. ويُفحص التحقق من المصادقة بـgetServerSession، وإذا لم توجد جلسة تُعاد استجابة HTTP 401.
متى نستخدم مسارات API مقابل إجراءات الخادم
يتداخل النهجان، لكن القاعدة العامة مباشرة.
استخدم إجراءات الخادم عندما يكون المستهلك واجهة Next.js الخاصة بك، مثل إرسال نموذج أو النقر على زر. فإجراءات الخادم مرتبطة بنموذج العرض في React، وتستفيد من التخزين المؤقت المدمج والتكامل مع إعادة التحقق، ولا تتطلب منك كتابة أي استدعاءات fetch في العميل.
استخدم مسارات API عندما تحتاج إلى نقطة نهاية HTTP يمكن لشيء خارج تطبيق Next.js استدعاؤها، مثلاً تطبيق جوال أو خطاف ويب (webhook) لخدمة طرف ثالث أو واجهة أمامية منفصلة. تتحدث مسارات API لغة HTTP العادية وتُعيد استجابات JSON قياسية، ما يجعل استهلاكها سهلاً من أي مكان.
في تطبيقنا تغطي إجراءات الخادم كل احتياجات الواجهة. أما مسارات /api/auth/ فهي موجودة لأن NextAuth تتطلب نقاط نهاية HTTP قياسية للتعامل مع مجرى المصادقة، الذي يتضمن إعادة توجيهات المتصفح وضبط ملفات تعريف الارتباط وهي أمور لم يُصمَّم نموذج إجراءات الخادم لأجلها. ولهذا احتجنا إلى معالجات المسارات للمصادقة رغم أن بقية التطبيق يستخدم إجراءات الخادم.
التمارين
18. صفحتي مع وصول برمز API
19. صفحتي مع وصول برمز API
20. قائمة القراءة
21. قائمة قراءة أفضل
بعض الأمور المعلّقة
Suspense والبث
عندما يجلب مكوّن خادم بيانات، تنتظر الصفحة بأكملها البيانات قبل إرسال أي شيء إلى المتصفح. وبالنسبة إلى الاستعلامات البطيئة يعني هذا أن المستخدم يحدّق في شاشة فارغة. وتعالج حدود Suspense في React هذه المشكلة بأن تتيح لك بث أجزاء من الواجهة فور جهوزيتها.
أبسط طريقة لإضافة واجهة تحميل إلى مسار هي إنشاء ملف loading.tsx في المجلد نفسه الذي يوجد فيه page.tsx. فيلفّ Next.js الصفحة تلقائياً في حدّ Suspense ويعرض مكوّن التحميل أثناء تجهيز الصفحة:
<em>// app/notes/loading.tsx</em>
const Loading = () => {
return <p>Loading notes...</p>
}
export default Loading
الآن عندما ينتقل المستخدم إلى /notes، يرى فوراً "Loading notes..." بينما يجلب مكوّن الخادم البيانات. وبمجرد جهوز البيانات، يبثّ Next.js محتوى الصفحة الفعلي ويستبدل حالة التحميل.
لمزيد من التحكم الدقيق، يمكنك لفّ المكوّنات فرادى يدوياً في حدود <Suspense> . يكون هذا مفيداً عندما تحتوي صفحة على عدة أقسام مستقلة تجلب البيانات، وتريد لكل منها أن يُحمَّل بشكل مستقل بدلاً من انتظار الصفحة بأكملها:
import { Suspense } from "react"
import NoteList from "./NoteList"
import Statistics from "./Statistics"
const Notes = () => {
return (
<div>
<h2>Notes</h2>
<Suspense fallback={<p>Loading notes...</p>}>
<NoteList />
</Suspense>
<Suspense fallback={<p>Loading statistics...</p>}>
<Statistics />
</Suspense>
</div>
)
}
يمكن أن يكون كل مكوّن ملفوف مكوّن خادم غير متزامن يجلب بياناته بنفسه. ويبثّ Next.js كل بديل (fallback) فوراً ويستبدله بالمحتوى الحقيقي بمجرد انتهاء ذلك المكوّن، فلا تعيق الأقسام البطيئة الأقسام السريعة.
تحسين الصور باستخدام next/image
في HTML العادي، كنت ستحمّل صورة بوسم <img>:
<img src="/profile.jpg" alt="User profile" width="200" height="200" />
هذا يعمل، لكن المتصفحات تحمّل الصورة بالحجم الكامل بغض النظر عن حجم شاشة الجهاز، ولا يوجد تحسين تلقائي. ويوفّر Next.js مكوّن Image الذي يحسّن الصور تلقائياً من أجل الأداء:
import Image from "next/image"
const Profile = () => {
return (
<div>
<Image
src="/profile.jpg"
alt="User profile"
width={200}
height={200}
/>
</div>
)
}
export default Profile
يقدّم مكوّن Image عدة فوائد رئيسية:
- تحسين الصيغة تلقائياً: يقدّم Next.js صيغاً حديثة مثل WebP وAVIF إلى المتصفحات التي تدعمها، مع الرجوع إلى الصيغة الأصلية للمتصفحات الأقدم.
- صور متجاوبة: يولّد المكوّن تلقائياً أحجاماً متعددة للصورة ويقدّم الحجم المناسب وفقاً لجهاز المستخدم.
- التحميل الكسول: لا تُحمَّل الصور خارج نافذة العرض حتى يقترب المستخدم من التمرير إليها، ما يقلّل زمن التحميل الأولي للصفحة.
- يمنع انزياح التخطيط: بفرض خاصيتي
widthوheightالصريحتين (أو استخدامfill)، يحجز المتصفح المساحة قبل تحميل الصورة، مانعاً القفز المفاجئ للمحتوى.
بالنسبة إلى الصور المخزّنة في مجلد public، يبدأ المسار بـ/:
<Image src="/images/logo.png" alt="Logo" width={100} height={50} />
بالنسبة إلى الصور الخارجية المستضافة على نطاقات أخرى، تحتاج إلى ضبط النطاقات المسموح بها في next.config.js:
<em>/** @type {import('next').NextConfig} */</em>
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
},
],
},
}
module.exports = nextConfig
ثم يمكنك استخدام الرابط الخارجي:
<Image
src="https://example.com/photo.jpg"
alt="Photo"
width={400}
height={300}
/>
إذا كنت لا تعرف أبعاد الصورة مسبقاً (مثلاً الصور التي يرفعها المستخدمون)، فيمكنك استخدام خاصية fill لجعل الصورة تملأ حاويتها الأب:
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/dynamic-image.jpg"
alt="Dynamic content"
fill
style={{ objectFit: 'cover' }}
/>
</div>
يجب أن تكون الحاوية الأب بـposition: relative أو position: fixed أو position: absolute حتى تعمل fill بشكل صحيح.
يُعدّ مكوّن Image أحد أهم تحسينات الأداء في Next.js. فاستخدامه بدلاً من وسوم <img> العادية يضمن توصيل صورك بكفاءة على جميع الأجهزة وظروف الشبكة.
البيانات الوصفية وSEO
يمتلك Next.js واجهة Metadata API مدمجة لضبط عنوان الصفحة ووصفها وغيرها من وسوم <head> بطريقة تعمل بشكل صحيح مع مكوّنات الخادم والبث.
أبسط صورة لذلك هي تصدير metadata ثابت في ملف page.tsx أو layout.tsx:
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Notes app",
description: "A simple notes application built with Next.js",
}
يدمج Next.js البيانات الوصفية من التخطيط الجذري نزولاً عبر التخطيطات والصفحات المتداخلة، فيمكن لصفحة فرعية تجاوز الحقول التي تحتاجها فقط دون تكرار كل شيء.
بالنسبة إلى الصفحات الديناميكية التي يعتمد عنوانها على البيانات، تصدّر دالة generateMetadata بدلاً من ذلك:
import type { Metadata } from "next"
import { getNoteById } from "../../services/notes"
export const generateMetadata = async ({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> => {
const { id } = await params
const note = await getNoteById(Number(id))
return {
title: note ? note.content.slice(0, 50) : "Note not found",
}
}
يعمل هذا على الخادم، ويظهر العنوان الناتج في وسم <title> في HTML، وهو أمر مهم لمحركات البحث ولعنوان علامة التبويب في المتصفح. وتتبع وسوم Open Graph الخاصة بالمشاركة الاجتماعية النمط نفسه باستخدام المفتاح openGraph داخل كائن البيانات الوصفية.
التمارين
22. صفحة رئيسية ثابتة من markdown
.markdown h1 {
font-size: 2.5rem;
font-weight: bold;
margin: 1.5rem 0 1rem;
}
.markdown h2 {
font-size: 1.875rem;
font-weight: bold;
margin: 2rem 0 0.75rem;
}
.markdown h3 {
font-size: 1.5rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
}
.markdown p {
margin: 1rem 0;
line-height: 1.7;
}
.markdown a {
color: #3b82f6;
text-decoration: underline;
}
.markdown a:hover {
color: #2563eb;
}
.markdown ul {
margin: 1rem 0;
padding-left: 2rem;
line-height: 1.7;
list-style-type: disc;
}
.markdown ol {
margin: 1rem 0;
padding-left: 2rem;
line-height: 1.7;
list-style-type: decimal;
}
.markdown li {
margin: 0.5rem 0;
}
.markdown hr {
margin: 2rem 0;
opacity: 0.2;
}
23. اللمسات الأخيرة
24. الفحص الأخير
25. مستودع GitHub الخاص بك