TipTap v3 مع Next.js 15: دليل بناء محرر نصوص غني للإنتاج

TipTap v3 هو محرر نصوص غني بلا واجهة افتراضية، مبني فوق ProseMirror. على عكس المحررات التقليدية التي تفرض تصميماً جاهزاً ثقيلاً، يمنحك TipTap تحكماً كاملاً في الترميز والأوامر والتنسيق. لذلك يعد الخيار المفضل في منتجات SaaS الحديثة مثل Linear و Gitbook وتطبيقات شبيهة بـ Notion وأنظمة إدارة المحتوى.
في هذا الدرس ستبني محرر نصوص متكامل في Next.js 15 باستخدام TipTap v3. بنهاية الدرس ستحصل على محرر جاهز للإنتاج يدعم التنسيق ورفع الصور والجداول وحفظ المحتوى في قاعدة البيانات بصيغة JSON مهيكلة.
المتطلبات الأساسية
قبل البدء تأكد من توفر ما يلي:
- Node.js 20 أو أحدث مثبتاً
- إلمام أساسي بـ Next.js App Router
- معرفة عملية بـ React و TypeScript
- محرر أكواد مثل VS Code
- مدير حزم مثل npm أو pnpm أو bun
ما ستبنيه
ستنشئ محرر نصوص يدعم:
- تنسيق النص: عريض، مائل، تسطير، يتوسطه خط، كود
- العناوين من H1 إلى H3، القوائم النقطية والمرقمة، الاقتباسات
- الروابط مع محرر منبثق
- الصور المضمنة مع نظام رفع ملفات
- الجداول مع أعمدة قابلة لتغيير الحجم
- شريط أدوات ثابت منسق بـ Tailwind CSS
- حفظ محتوى المحرر في صيغة JSON عبر API
- عرض المحتوى المحفوظ لاحقاً بصيغة HTML في صفحة للقراءة فقط
الخطوة 1: إنشاء مشروع Next.js 15 جديد
ابدأ بإنشاء مشروع Next.js حديث يستخدم App Router و TypeScript.
npx create-next-app@latest tiptap-editor --typescript --tailwind --app --src-dir=false --import-alias "@/*"
cd tiptap-editorعند الطلب اختر الخيارات التالية:
- TypeScript: نعم
- ESLint: نعم
- Tailwind CSS: نعم
- App Router: نعم
- Turbopack: نعم
الخطوة 2: تثبيت TipTap v3 والإضافات
ثبت حزم TipTap الأساسية مع الحزمة الجاهزة والإضافات التي سنستخدمها.
npm install @tiptap/react @tiptap/starter-kit @tiptap/pm
npm install @tiptap/extension-link @tiptap/extension-image
npm install @tiptap/extension-placeholder @tiptap/extension-table
npm install @tiptap/extension-table-row @tiptap/extension-table-cell @tiptap/extension-table-headerتوفر حزمة @tiptap/starter-kit أغلب الإضافات المستخدمة بكثرة مثل الفقرات والخط العريض والمائل والعناوين والقوائم وسجل التراجع. أما بقية الإضافات فنثبتها منفصلة لتضمين ما نحتاج فقط وتقليل حجم الحزمة النهائية.
الخطوة 3: إنشاء مكون المحرر
أنشئ ملفاً جديداً باسم components/editor/Editor.tsx يغلف TipTap ويقدم واجهة React نظيفة.
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import Toolbar from "./Toolbar";
type EditorProps = {
content?: string;
onChange?: (html: string, json: unknown) => void;
editable?: boolean;
};
export default function Editor({ content = "", onChange, editable = true }: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { class: "text-blue-600 underline" },
}),
Image.configure({ inline: false, allowBase64: false }),
Placeholder.configure({
placeholder: "ابدأ الكتابة هنا...",
}),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content,
editable,
immediatelyRender: false,
editorProps: {
attributes: {
class: "prose prose-slate max-w-none focus:outline-none min-h-[320px] p-4",
},
},
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML(), editor.getJSON());
},
});
if (!editor) return null;
return (
<div className="border rounded-lg bg-white shadow-sm">
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}هناك تفصيلان مهمان:
- الخيار
immediatelyRender: falseضروري في Next.js 15 لتفادي أخطاء hydration لأن TipTap يعمل على جهة العميل. - الصنف
proseمن Tailwind Typography يمنحك تنسيقاً جميلاً افتراضياً للعناوين والقوائم والاقتباسات دون جهد إضافي.
الخطوة 4: تثبيت Tailwind Typography
يأتي TipTap بدون أي تنسيقات. توفر إضافة Tailwind Typography تجربة قراءة أنيقة من دون إعدادات كثيرة.
npm install -D @tailwindcss/typographyأضف الإضافة إلى إعدادات Tailwind. في Tailwind v4 مع Next.js 15 أضف التوجيه التالي داخل ملف app/globals.css:
@import "tailwindcss";
@plugin "@tailwindcss/typography";الخطوة 5: بناء شريط الأدوات
أنشئ ملف components/editor/Toolbar.tsx مع أزرار التنسيق. كل زر يستخدم editor.chain() لتطبيق الأوامر.
"use client";
import type { Editor } from "@tiptap/react";
import {
Bold, Italic, Strikethrough, Code, Heading1, Heading2, Heading3,
List, ListOrdered, Quote, Undo, Redo, Link as LinkIcon, Image as ImgIcon,
Table as TableIcon,
} from "lucide-react";
type Props = { editor: Editor };
function ToolbarButton({
onClick,
active,
disabled,
children,
title,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
children: React.ReactNode;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-2 rounded hover:bg-slate-100 transition ${
active ? "bg-slate-200 text-slate-900" : "text-slate-600"
} disabled:opacity-40`}
>
{children}
</button>
);
}
export default function Toolbar({ editor }: Props) {
const addLink = () => {
const url = window.prompt("أدخل الرابط");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
const addTable = () => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
};
return (
<div className="flex flex-wrap gap-1 p-2 border-b bg-slate-50 sticky top-0 z-10">
<ToolbarButton title="Bold" onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")}>
<Bold size={16} />
</ToolbarButton>
<ToolbarButton title="Italic" onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")}>
<Italic size={16} />
</ToolbarButton>
<ToolbarButton title="Strike" onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")}>
<Strikethrough size={16} />
</ToolbarButton>
<ToolbarButton title="Code" onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive("code")}>
<Code size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="H1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })}>
<Heading1 size={16} />
</ToolbarButton>
<ToolbarButton title="H2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })}>
<Heading2 size={16} />
</ToolbarButton>
<ToolbarButton title="H3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })}>
<Heading3 size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")}>
<List size={16} />
</ToolbarButton>
<ToolbarButton title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")}>
<ListOrdered size={16} />
</ToolbarButton>
<ToolbarButton title="Quote" onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")}>
<Quote size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Link" onClick={addLink} active={editor.isActive("link")}>
<LinkIcon size={16} />
</ToolbarButton>
<ToolbarButton title="Table" onClick={addTable}>
<TableIcon size={16} />
</ToolbarButton>
<span className="w-px bg-slate-300 mx-1" />
<ToolbarButton title="Undo" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
<Undo size={16} />
</ToolbarButton>
<ToolbarButton title="Redo" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
<Redo size={16} />
</ToolbarButton>
</div>
);
}ثبت مكتبة Lucide للحصول على الأيقونات إذا لم تفعل من قبل.
npm install lucide-reactالخطوة 6: إضافة صفحة تستخدم المحرر
أنشئ الملف app/editor/page.tsx لاستضافة المحرر والتقاط مخرجاته.
"use client";
import { useState } from "react";
import Editor from "@/components/editor/Editor";
export default function EditorPage() {
const [html, setHtml] = useState("");
const [json, setJson] = useState<unknown>(null);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
const res = await fetch("/api/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, json }),
});
setSaving(false);
if (!res.ok) alert("فشل الحفظ");
};
return (
<main className="max-w-3xl mx-auto p-8 space-y-4">
<h1 className="text-3xl font-bold">مستند جديد</h1>
<Editor onChange={(h, j) => { setHtml(h); setJson(j); }} />
<button
onClick={save}
disabled={saving}
className="px-4 py-2 bg-slate-900 text-white rounded hover:bg-slate-800 disabled:opacity-50"
>
{saving ? "جارٍ الحفظ..." : "حفظ"}
</button>
</main>
);
}الخطوة 7: إنشاء نقطة نهاية للحفظ
خزن المستندات بصيغة JSON. استخدام JSON بدل HTML يحمي بياناتك مستقبلاً لأنك تستطيع إعادة عرضها لاحقاً بإعدادات محرر مختلفة.
أنشئ الملف app/api/documents/route.ts.
import { NextResponse } from "next/server";
type Payload = { html: string; json: unknown };
export async function POST(req: Request) {
const body = (await req.json()) as Payload;
if (!body.json) {
return NextResponse.json({ error: "Missing json" }, { status: 400 });
}
// استبدل هذا باتصال قاعدة البيانات لديك مثل Drizzle أو Prisma.
// await db.insert(documents).values({ html: body.html, json: body.json });
return NextResponse.json({ ok: true });
}في بيئة الإنتاج احفظ حقل json في عمود JSONB داخل Postgres. أما حقل html فهو مفيد لفهرسة البحث أو العرض السريع لكن يجب التعامل معه كذاكرة مشتقة من JSON.
الخطوة 8: تنفيذ رفع الصور
بدل إدخال رابط مباشر، اسمح للمستخدمين برفع الصور. ابدأ بإضافة مسار رفع في app/api/uploads/route.ts.
import { NextResponse } from "next/server";
import { writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
export async function POST(req: Request) {
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const buffer = Buffer.from(await file.arrayBuffer());
const ext = path.extname(file.name) || ".png";
const name = `${randomUUID()}${ext}`;
const dest = path.join(process.cwd(), "public", "uploads", name);
await writeFile(dest, buffer);
return NextResponse.json({ url: `/uploads/${name}` });
}في الإنتاج استبدل التخزين المحلي بعميل S3 أو خدمة Vercel Blob. الأسلوب المحلي للتطوير فقط.
ثم حدّث زر الصورة في شريط الأدوات ليرفع ملفاً بدل طلب رابط.
const uploadImage = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: form });
const { url } = await res.json();
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
};
input.click();
};استبدل المعالج السابق addImage بهذا التدفق الجديد. سيدرج TipTap الصورة فور عودة استجابة الخادم.
الخطوة 9: عرض المحتوى المحفوظ
أنشئ صفحة قراءة فقط في app/documents/[id]/page.tsx تعرض JSON المحفوظ كـ HTML باستخدام نفس الإضافات.
import { generateHTML } from "@tiptap/html";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
type Params = { params: Promise<{ id: string }> };
export default async function DocumentPage({ params }: Params) {
const { id } = await params;
const doc = await loadDocumentById(id); // نفذ الاستعلام حسب قاعدة بياناتك
const html = generateHTML(doc.json, [
StarterKit,
Link,
Image,
Table,
TableRow,
TableHeader,
TableCell,
]);
return (
<article
className="prose prose-slate max-w-3xl mx-auto p-8"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}بما أن generateHTML يعمل داخل مكون React Server Component وقت الطلب، فإن العميل لا يحتاج تحميل TipTap لمجرد عرض المحتوى. يعطي هذا صفحات قراءة سريعة جداً.
الخطوة 10: إضافة التحرير التعاوني (اختياري)
يقدم TipTap تكاملاً مباشراً مع Yjs لدعم التعاون اللحظي. ثبت الحزم اللازمة.
npm install @tiptap/extension-collaboration yjs y-websocketثم استبدل سجل التراجع في StarterKit بسجل Yjs واربط الموفر.
import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider("wss://your-ws-server", "room-1", ydoc);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
],
});في الإنتاج استخدم خادم Yjs مثل Hocuspocus أو خدمة TipTap Cloud.
الخطوة 11: تأمين مخرجات HTML
إذا عرضت HTML المخزن مباشرة باستخدام dangerouslySetInnerHTML فيجب تنظيفه في الخادم دائماً. يمكن للمهاجمين حقن سكربتات عبر اللصق. استخدم مكتبة مثل isomorphic-dompurify قبل الوثوق بأي HTML.
import DOMPurify from "isomorphic-dompurify";
const safe = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });البديل الأكثر أماناً هو الاعتماد على JSON بدل HTML، فهو لا يحتوي نصوصاً قابلة للتنفيذ بحكم التصميم لأن TipTap يُصدر فقط أنواع العُقد المعروفة.
الخطوة 12: تقليص حجم الحزمة
حزم TipTap قابلة للـ tree-shaking، لكن حزمة الإضافات الكاملة تضيف ما يقارب 120 كيلوبايت مضغوطة إلى حزمة العميل. قلل هذا الحجم بطريقتين:
- استورد المحرر ديناميكياً عبر
next/dynamicمعssr: false - ثبت فقط الإضافات التي تستخدمها فعلاً بدلاً من الحزمة الكاملة
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@/components/editor/Editor"), {
ssr: false,
loading: () => <div className="h-80 animate-pulse bg-slate-100 rounded-lg" />,
});اختبار التطبيق
شغل خادم التطوير وافتح /editor في المتصفح.
npm run devتحقق من السلوكيات التالية:
- الكتابة تنشئ فقرات ويختفي نص الـ placeholder
- أزرار العريض والمائل والكود تبدّل حالة النص المحدد
- أزرار العناوين تغير نوع البلوك الحالي
- القوائم والاقتباسات تغلف الفقرة النشطة
- زر الرابط يفتح مربع حوار ويُنتج رابطاً قابلاً للضغط
- رفع الصورة يدرجها في المستند فور عودة استجابة الخادم
- إدراج الجدول ينشئ شبكة 3x3 بأعمدة قابلة لتغيير الحجم
- ضغط زر الحفظ يرسل POST مع حقلي
htmlوjson - إعادة تحميل المستند المحفوظ يعرض المحتوى نفسه دون أدوات التحرير
استكشاف الأخطاء
تحذيرات hydration عند التحميل. اضبط immediatelyRender: false عند استدعاء useEditor. يمنع هذا TipTap من الظهور أثناء SSR.
الخطأ document is not defined عند البناء. أضف "use client" في بداية ملفات المحرر دائماً واستورد المحرر ديناميكياً في الصفحات المعروضة على الخادم.
الجداول لا تُعرض. تأكد من تسجيل إضافات الجداول الأربع (Table, TableRow, TableHeader, TableCell) في محرر العميل وفي استدعاء generateHTML على الخادم.
الصور الملصقة تتحول إلى نصوص base64 ضخمة. اضبط Image مع allowBase64: false واعترض أحداث اللصق لرفع الملف أولاً.
الخطوات التالية
بعد أن أصبح لديك محرر يعمل، فكر في توسيعه أكثر.
- أضف أوامر slash تظهر قائمة عند كتابة
/مثل Notion - نفذ الإشارات (mentions) باستخدام
@tiptap/extension-mention - ادمج إكمال الذكاء الاصطناعي عبر Vercel AI SDK إلى جانب المحرر، وهو ما نغطيه في درس بناء وكيل ذكاء اصطناعي مع Vercel AI SDK
- ادعم الملفات المرفقة بتوسيع إضافة Image إلى عقدة File عامة
- احفظ المسودات تلقائياً عبر خطاف autosave مع تأخير
الخاتمة
يقدم TipTap v3 محرراً احترافياً قابلاً للتوسيع يندمج بسلاسة مع Next.js 15. أصبح لديك الآن دورة كاملة للكتابة والعرض مع التنسيق والوسائط وتخزين JSON المهيكل. بما أن المستند الأساسي ليس HTML خاماً فيمكنك تطوير المخطط مع الوقت دون فقدان التوافق مع البيانات القديمة.
للتخصيص المتقدم استكشف بناء عُقد مخصصة عبر واجهة Node.create()، التي تتيح لك إضافة قوالب تفاعلية أو مخططات أو أنواع محتوى خاصة بمجالك. لم يعد محرر النص مكوناً عادياً — مع TipTap يتحول إلى واجهة منتج أساسية تملكها بالكامل.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

بناء تطبيق متكامل باستخدام Appwrite Cloud و Next.js 15
تعلّم كيفية بناء تطبيق ويب متكامل باستخدام Appwrite Cloud كخدمة خلفية و Next.js 15 مع App Router. يغطي هذا الدليل المصادقة وقواعد البيانات وتخزين الملفات والميزات الفورية.

بناء تطبيق كامل يعمل بالوقت الحقيقي باستخدام Convex و Next.js 15
تعلّم كيفية بناء تطبيق كامل يعمل بالوقت الحقيقي باستخدام Convex و Next.js 15. يغطي هذا الدليل تصميم المخططات والاستعلامات والتعديلات والاشتراكات الفورية والمصادقة ورفع الملفات — مع أمان أنواع شامل.

بناء تطبيق كامل مع Firebase و Next.js 15: المصادقة، Firestore والتحديث الفوري
تعلم كيفية بناء تطبيق full-stack مع Next.js 15 و Firebase. يغطي هذا الدليل المصادقة، Firestore، التحديثات الفورية، Server Actions والنشر على Vercel.