Medusa.js 2.0 — بناء متجر إلكتروني Headless مع Next.js (2026)

بديل مفتوح المصدر لـ Shopify، مصمم للمطورين. Medusa.js 2.0 هي منصة تجارة إلكترونية معيارية بالكامل بدون واجهة أمامية مقيّدة، تتيح لك بناء واجهات متاجر مخصصة مع أي إطار عمل. في هذا الدليل، نربطها مع Next.js لبناء متجر جاهز للإنتاج.
ما ستتعلمه
بنهاية هذا الدليل، ستكون قادراً على:
- فهم بنية Medusa.js 2.0 وتصميمها المعياري
- إعداد خادم Medusa مع PostgreSQL
- بناء واجهة متجر Next.js متصلة بـ Medusa API
- عرض المنتجات مع التصنيفات والمتغيرات
- تنفيذ سلة تسوق بوظائف الإضافة والحذف
- بناء تدفق دفع مع الشحن والدفع
- نشر الخادم والواجهة الأمامية في بيئة الإنتاج
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت (
node --version) - PostgreSQL 14+ يعمل محلياً أو عبر Docker
- معرفة بـ React و Next.js (App Router، Server Components)
- أساسيات TypeScript
- محرر أكواد — يُفضّل VS Code
- فهم أساسي لـ REST APIs
لماذا Medusa.js 2.0؟
إذا جربت تخصيص Shopify خارج قوالب Liquid أو عانيت مع إضافات WooCommerce، فأنت تعرف المعاناة. Medusa.js 2.0 تتبع نهجاً مختلفاً:
| الميزة | Medusa 2.0 | Shopify | WooCommerce |
|---|---|---|---|
| مفتوح المصدر | نعم | لا | نعم |
| Headless-first | نعم | جزئي | بإضافة |
| وحدات مخصصة | درجة أولى | محدود | إضافات |
| متعدد المناطق | مدمج | مكلف | يدوي |
| TypeScript | كامل | Liquid | PHP |
| استضافة ذاتية | نعم | لا | نعم |
أُعيد كتابة Medusa 2.0 بالكامل من الصفر بـ بنية معيارية. كل ميزة — المنتجات، السلة، الطلبات، المدفوعات — هي وحدة مستقلة يمكنك توسيعها أو استبدالها أو إزالتها.
نظرة على البنية
┌─────────────────────┐ ┌──────────────────────┐
│ واجهة Next.js │────▶│ خادم Medusa.js │
│ (App Router) │ API │ (Node.js + Express) │
│ المنفذ 3000 │◀────│ المنفذ 9000 │
└─────────────────────┘ └──────────┬───────────┘
│
┌──────────▼───────────┐
│ PostgreSQL │
│ + Redis │
└──────────────────────┘
الخطوة 1: إعداد خادم Medusa
تثبيت Medusa CLI
npm install -g @medusajs/medusa-cliإنشاء مشروع Medusa جديد
medusa new my-store --v2
cd my-storeهذا ينشئ مشروع Medusa 2.0 يحتوي على:
src/— الوحدات المخصصة ومسارات APImedusa-config.ts— الإعدادات المركزيةpackage.json— مع جميع الوحدات الأساسية مثبتة مسبقاً
إعداد قاعدة البيانات
عدّل medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL ||
"postgres://localhost/medusa-store",
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
http: {
storeCors: process.env.STORE_CORS || "http://localhost:3000",
adminCors: process.env.ADMIN_CORS || "http://localhost:9000",
jwtSecret: process.env.JWT_SECRET || "supersecret",
cookieSecret: process.env.COOKIE_SECRET || "supersecret",
},
},
})إنشاء قاعدة البيانات وتعبئة البيانات التجريبية
# إنشاء قاعدة بيانات PostgreSQL
createdb medusa-store
# تشغيل الترحيلات
npx medusa db:migrate
# تعبئة ببيانات منتجات تجريبية
npx medusa seed --seed-file=src/scripts/seed.tsتشغيل الخادم
npx medusa developخادم Medusa الآن يعمل على http://localhost:9000. اختبره:
curl http://localhost:9000/store/products | jq '.products[0].title'الخطوة 2: إنشاء واجهة المتجر بـ Next.js
تهيئة المشروع
npx create-next-app@latest my-storefront --typescript --tailwind --app --src-dir
cd my-storefrontتثبيت التبعيات
npm install @medusajs/js-sdk @medusajs/typesإعداد عميل Medusa
أنشئ src/lib/medusa.ts:
import Medusa from "@medusajs/js-sdk"
export const medusa = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL || "http://localhost:9000",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || "",
})أضف إلى .env.local:
NEXT_PUBLIC_MEDUSA_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_your_key_hereيمكنك إيجاد مفتاح API القابل للنشر في لوحة تحكم Medusa على http://localhost:9000/app.
الخطوة 3: بناء كتالوج المنتجات
صفحة قائمة المنتجات
أنشئ src/app/products/page.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
export default async function ProductsPage() {
const { products } = await medusa.store.product.list({
limit: 20,
fields: "+variants.calculated_price",
})
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">جميع المنتجات</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.handle}`}
className="group"
>
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
<p className="text-gray-500">
{formatPrice(product.variants?.[0]?.calculated_price)}
</p>
</Link>
))}
</div>
</main>
)
}
function formatPrice(price: any) {
if (!price?.calculated_amount) return ""
return new Intl.NumberFormat("ar-SA", {
style: "currency",
currency: price.currency_code || "usd",
}).format(price.calculated_amount / 100)
}صفحة تفاصيل المنتج
أنشئ src/app/products/[handle]/page.tsx:
import { medusa } from "@/lib/medusa"
import { notFound } from "next/navigation"
import Image from "next/image"
import { AddToCartButton } from "@/components/add-to-cart"
interface Props {
params: Promise<{ handle: string }>
}
export default async function ProductPage({ params }: Props) {
const { handle } = await params
const { products } = await medusa.store.product.list({
handle,
fields: "+variants.calculated_price,+variants.inventory_quantity",
})
const product = products[0]
if (!product) notFound()
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* صور المنتج */}
<div className="space-y-4">
{product.images?.map((image, i) => (
<div key={i} className="aspect-square rounded-xl overflow-hidden bg-gray-100">
<Image
src={image.url}
alt={product.title}
width={800}
height={800}
className="object-cover w-full h-full"
/>
</div>
))}
</div>
{/* معلومات المنتج */}
<div>
<h1 className="text-3xl font-bold">{product.title}</h1>
<p className="mt-2 text-2xl text-gray-700">
{formatPrice(product.variants?.[0]?.calculated_price)}
</p>
<div
className="mt-6 prose"
dangerouslySetInnerHTML={{ __html: product.description || "" }}
/>
{/* اختيار المتغير */}
{product.options?.map((option) => (
<div key={option.id} className="mt-6">
<h3 className="font-medium mb-2">{option.title}</h3>
<div className="flex gap-2">
{option.values?.map((value) => (
<button
key={value.id}
className="px-4 py-2 border rounded-lg hover:border-black transition"
>
{value.value}
</button>
))}
</div>
</div>
))}
<AddToCartButton
productId={product.id}
variantId={product.variants?.[0]?.id || ""}
/>
</div>
</div>
</main>
)
}
function formatPrice(price: any) {
if (!price?.calculated_amount) return ""
return new Intl.NumberFormat("ar-SA", {
style: "currency",
currency: price.currency_code || "usd",
}).format(price.calculated_amount / 100)
}الخطوة 4: تنفيذ سلة التسوق
مزوّد سياق السلة
أنشئ src/lib/cart-context.tsx:
"use client"
import { createContext, useContext, useState, useEffect, useCallback } from "react"
import { medusa } from "@/lib/medusa"
interface CartContextType {
cart: any
addItem: (variantId: string, quantity?: number) => Promise<void>
removeItem: (lineItemId: string) => Promise<void>
updateQuantity: (lineItemId: string, quantity: number) => Promise<void>
itemCount: number
}
const CartContext = createContext<CartContextType | null>(null)
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<any>(null)
const initCart = useCallback(async () => {
let cartId = localStorage.getItem("cart_id")
if (cartId) {
try {
const { cart } = await medusa.store.cart.retrieve(cartId)
setCart(cart)
return
} catch {
localStorage.removeItem("cart_id")
}
}
const { cart: newCart } = await medusa.store.cart.create({})
localStorage.setItem("cart_id", newCart.id)
setCart(newCart)
}, [])
useEffect(() => {
initCart()
}, [initCart])
const addItem = async (variantId: string, quantity = 1) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.addLineItem(cart.id, {
variant_id: variantId,
quantity,
})
setCart(updated)
}
const removeItem = async (lineItemId: string) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.removeLineItem(
cart.id,
lineItemId
)
setCart(updated)
}
const updateQuantity = async (lineItemId: string, quantity: number) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.updateLineItem(
cart.id,
lineItemId,
{ quantity }
)
setCart(updated)
}
const itemCount = cart?.items?.reduce(
(sum: number, item: any) => sum + item.quantity, 0
) || 0
return (
<CartContext.Provider value={{ cart, addItem, removeItem, updateQuantity, itemCount }}>
{children}
</CartContext.Provider>
)
}
export const useCart = () => {
const ctx = useContext(CartContext)
if (!ctx) throw new Error("useCart must be used within CartProvider")
return ctx
}زر الإضافة للسلة
أنشئ src/components/add-to-cart.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
export function AddToCartButton({
variantId,
}: {
productId: string
variantId: string
}) {
const { addItem } = useCart()
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addItem(variantId)
setLoading(false)
}
return (
<button
onClick={handleAdd}
disabled={loading}
className="mt-8 w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50 transition"
>
{loading ? "جاري الإضافة..." : "أضف إلى السلة"}
</button>
)
}مكوّن درج السلة
أنشئ src/components/cart-drawer.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
export function CartDrawer() {
const { cart, removeItem, updateQuantity, itemCount } = useCart()
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)} className="relative">
السلة
{itemCount > 0 && (
<span className="absolute -top-2 -right-3 bg-black text-white text-xs
w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
)}
</button>
{open && (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="bg-black/50 flex-1" onClick={() => setOpen(false)} />
<div className="w-full max-w-md bg-white h-full overflow-y-auto p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">سلة التسوق</h2>
<button onClick={() => setOpen(false)}>إغلاق</button>
</div>
{!cart?.items?.length ? (
<p className="text-gray-500">سلة التسوق فارغة.</p>
) : (
<>
{cart.items.map((item: any) => (
<div key={item.id} className="flex gap-4 py-4 border-b">
{item.thumbnail && (
<Image
src={item.thumbnail}
alt={item.title}
width={80}
height={80}
className="rounded-lg object-cover"
/>
)}
<div className="flex-1">
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-gray-500">{item.variant?.title}</p>
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
className="w-8 h-8 border rounded"
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 border rounded"
>
+
</button>
</div>
</div>
<button
onClick={() => removeItem(item.id)}
className="text-red-500 text-sm"
>
حذف
</button>
</div>
))}
<div className="mt-6 pt-4 border-t">
<div className="flex justify-between text-lg font-bold">
<span>المجموع</span>
<span>{formatPrice(cart.total, cart.currency_code)}</span>
</div>
<Link
href="/checkout"
onClick={() => setOpen(false)}
className="block mt-4 w-full bg-black text-white text-center
py-4 rounded-xl font-medium hover:bg-gray-800"
>
إتمام الشراء
</Link>
</div>
</>
)}
</div>
</div>
)}
</>
)
}
function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("ar-SA", {
style: "currency",
currency: currency || "usd",
}).format(amount / 100)
}الخطوة 5: بناء تدفق الدفع
صفحة الدفع
أنشئ src/app/checkout/page.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { medusa } from "@/lib/medusa"
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function CheckoutPage() {
const { cart } = useCart()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<"shipping" | "payment">("shipping")
const [shippingForm, setShippingForm] = useState({
first_name: "",
last_name: "",
address_1: "",
city: "",
country_code: "sa",
postal_code: "",
phone: "",
email: "",
})
const handleShippingSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!cart) return
setLoading(true)
// إضافة البريد الإلكتروني للسلة
await medusa.store.cart.update(cart.id, {
email: shippingForm.email,
})
// إضافة عنوان الشحن
await medusa.store.cart.update(cart.id, {
shipping_address: {
first_name: shippingForm.first_name,
last_name: shippingForm.last_name,
address_1: shippingForm.address_1,
city: shippingForm.city,
country_code: shippingForm.country_code,
postal_code: shippingForm.postal_code,
phone: shippingForm.phone,
},
})
// الحصول على خيارات الشحن
const { shipping_options } = await medusa.store.fulfillment
.listCartOptions({ cart_id: cart.id })
if (shipping_options?.length) {
await medusa.store.cart.addShippingMethod(cart.id, {
option_id: shipping_options[0].id,
})
}
setLoading(false)
setStep("payment")
}
const handlePaymentSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!cart) return
setLoading(true)
// تهيئة جلسة الدفع
await medusa.store.cart.initiatePaymentSession(cart.id, {
provider_id: "pp_system_default",
})
// إتمام السلة (إنشاء الطلب)
const { order } = await medusa.store.cart.complete(cart.id) as any
localStorage.removeItem("cart_id")
router.push(`/order/${order.id}`)
}
if (!cart) return <div className="p-12 text-center">جاري تحميل السلة...</div>
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">إتمام الشراء</h1>
{/* مؤشر التقدم */}
<div className="flex gap-4 mb-8">
<span className={step === "shipping" ? "font-bold" : "text-gray-400"}>
1. الشحن
</span>
<span className={step === "payment" ? "font-bold" : "text-gray-400"}>
2. الدفع
</span>
</div>
{step === "shipping" && (
<form onSubmit={handleShippingSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<input
placeholder="الاسم الأول"
required
value={shippingForm.first_name}
onChange={(e) => setShippingForm(f => ({ ...f, first_name: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
<input
placeholder="اسم العائلة"
required
value={shippingForm.last_name}
onChange={(e) => setShippingForm(f => ({ ...f, last_name: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
</div>
<input
placeholder="البريد الإلكتروني"
type="email"
required
value={shippingForm.email}
onChange={(e) => setShippingForm(f => ({ ...f, email: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<input
placeholder="العنوان"
required
value={shippingForm.address_1}
onChange={(e) => setShippingForm(f => ({ ...f, address_1: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<div className="grid grid-cols-2 gap-4">
<input
placeholder="المدينة"
required
value={shippingForm.city}
onChange={(e) => setShippingForm(f => ({ ...f, city: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
<input
placeholder="الرمز البريدي"
required
value={shippingForm.postal_code}
onChange={(e) => setShippingForm(f => ({ ...f, postal_code: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
</div>
<input
placeholder="رقم الهاتف"
value={shippingForm.phone}
onChange={(e) => setShippingForm(f => ({ ...f, phone: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "جاري المعالجة..." : "المتابعة إلى الدفع"}
</button>
</form>
)}
{step === "payment" && (
<form onSubmit={handlePaymentSubmit} className="space-y-4">
<div className="bg-gray-50 rounded-xl p-6">
<h3 className="font-medium mb-2">ملخص الطلب</h3>
{cart.items?.map((item: any) => (
<div key={item.id} className="flex justify-between py-2">
<span>{item.title} x {item.quantity}</span>
<span>{formatPrice(item.subtotal, cart.currency_code)}</span>
</div>
))}
<div className="border-t mt-4 pt-4 flex justify-between font-bold">
<span>المجموع</span>
<span>{formatPrice(cart.total, cart.currency_code)}</span>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "جاري تأكيد الطلب..." : "تأكيد الطلب"}
</button>
</form>
)}
</main>
)
}
function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("ar-SA", {
style: "currency",
currency: currency || "usd",
}).format(amount / 100)
}الخطوة 6: التصنيفات وتصفية المنتجات
جلب التصنيفات
أنشئ src/app/categories/[handle]/page.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
interface Props {
params: Promise<{ handle: string }>
}
export default async function CategoryPage({ params }: Props) {
const { handle } = await params
const { product_categories } = await medusa.store.category.list({
handle,
})
const category = product_categories[0]
if (!category) return <div>التصنيف غير موجود</div>
const { products } = await medusa.store.product.list({
category_id: [category.id],
fields: "+variants.calculated_price",
})
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-2">{category.name}</h1>
<p className="text-gray-500 mb-8">{category.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.handle}`} className="group">
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
</Link>
))}
</div>
</main>
)
}شريط التنقل بين التصنيفات
أنشئ src/components/category-nav.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
export async function CategoryNav() {
const { product_categories } = await medusa.store.category.list({
parent_category_id: "null",
})
return (
<nav className="flex gap-6 overflow-x-auto py-4 px-4 border-b">
<Link href="/products" className="whitespace-nowrap hover:text-black text-gray-600">
جميع المنتجات
</Link>
{product_categories.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.handle}`}
className="whitespace-nowrap hover:text-black text-gray-600"
>
{cat.name}
</Link>
))}
</nav>
)
}الخطوة 7: وظيفة البحث
أنشئ src/app/search/page.tsx:
"use client"
import { medusa } from "@/lib/medusa"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { useSearchParams } from "next/navigation"
export default function SearchPage() {
const searchParams = useSearchParams()
const query = searchParams.get("q") || ""
const [products, setProducts] = useState<any[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return
setLoading(true)
medusa.store.product
.list({ q: query, limit: 20 })
.then(({ products }) => setProducts(products))
.finally(() => setLoading(false))
}, [query])
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">
نتائج البحث عن "{query}"
</h1>
{loading && <p>جاري البحث...</p>}
{!loading && products.length === 0 && query && (
<p className="text-gray-500">لم يتم العثور على منتجات.</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.handle}`} className="group">
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
</Link>
))}
</div>
</main>
)
}الخطوة 8: دعم المناطق المتعددة
من أبرز ميزات Medusa 2.0 الدعم المدمج للمناطق المتعددة. هذا مثالي لمتاجر منطقة الشرق الأوسط وشمال أفريقيا التي تخدم عدة دول.
إعداد المناطق
في لوحة تحكم Medusa (http://localhost:9000/app)، أنشئ مناطق:
- الخليج العربي — العملة: SAR، الدول: SA, AE, KW, BH, OM, QA
- شمال أفريقيا — العملة: TND، الدول: TN, DZ, MA, LY, EG
- دولي — العملة: USD، الدول: US, GB, DE, FR
كشف المنطقة في Next.js
أنشئ src/lib/region.ts:
import { medusa } from "./medusa"
export async function getRegionByCountry(countryCode: string) {
const { regions } = await medusa.store.region.list()
return regions.find((region) =>
region.countries?.some((c) => c.iso_2 === countryCode)
)
}
export async function getDefaultRegion() {
const { regions } = await medusa.store.region.list()
return regions[0]
}الخطوة 9: النشر في بيئة الإنتاج
نشر خادم Medusa
الخيار أ: Railway (موصى به)
# تثبيت Railway CLI
npm install -g @railway/cli
# تسجيل الدخول والنشر
railway login
railway init
railway add --plugin postgresql
railway add --plugin redis
# تعيين متغيرات البيئة
railway variables set JWT_SECRET="your-production-secret"
railway variables set COOKIE_SECRET="your-cookie-secret"
railway variables set STORE_CORS="https://your-storefront.vercel.app"
# النشر
railway upالخيار ب: Docker
أنشئ Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
EXPOSE 9000
CMD ["npm", "start"]نشر واجهة Next.js
# النشر على Vercel
npx vercel --prod
# تعيين متغيرات البيئة في لوحة تحكم Vercel
# NEXT_PUBLIC_MEDUSA_URL=https://your-medusa-backend.railway.app
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_live_xxxحل المشاكل الشائعة
المشاكل المتكررة
أخطاء CORS عند ربط الواجهة الأمامية بالخادم
تأكد أن STORE_CORS في إعدادات Medusa يحتوي على رابط واجهتك الأمامية:
storeCors: "http://localhost:3000,https://your-store.vercel.app"المنتجات لا تظهر الأسعار
تأكد من تمرير region_id أو أن لديك مفتاح API قابل للنشر مع قناة مبيعات افتراضية:
const { products } = await medusa.store.product.list({
region_id: "reg_xxx",
fields: "+variants.calculated_price",
})فشل إنشاء السلة
تحقق أن Redis يعمل — Medusa 2.0 تستخدم Redis لجلسات السلة:
redis-cli ping # يجب أن يُرجع PONGالخطوات التالية
الآن بعد أن أصبح لديك متجر إلكتروني يعمل، يمكنك:
- إضافة مدفوعات Stripe — ثبّت
@medusajs/payment-stripeلمعالجة الدفع الحقيقي - إعداد Webhooks — لمعالجة أحداث الطلبات وإشعارات البريد الإلكتروني
- بناء لوحة تحكم مخصصة — استخدم واجهة إدارة Medusa أو أنشئ صفحات مخصصة
- إضافة تقييمات المنتجات — أنشئ وحدة مخصصة لمراجعات المستخدمين
- تنفيذ قائمة المفضلات — استخدم نقاط النهاية المخصصة لتخزين المفضلات
- تحسين SEO — أضف علامات meta وبيانات هيكلية وخرائط الموقع
الخلاصة
لقد بنيت متجراً إلكترونياً كاملاً بدون واجهة مقيّدة باستخدام Medusa.js 2.0 و Next.js. البنية المعيارية تعني أنه يمكنك تبديل أي جزء — استخدم Stripe بدلاً من مزود الدفع الافتراضي، أضف وحدة شحن مخصصة، أو اربط واجهة أمامية مختلفة تماماً.
Medusa 2.0 هي بديل مفتوح المصدر جاد لـ Shopify للمطورين الذين يريدون التحكم الكامل في منصة التجارة الخاصة بهم. الجمع مع Next.js يمنحك التصيير من جانب الخادم والتوليد الساكن وتجربة مطور عالمية المستوى.
الكود المصدري الكامل لهذا الدليل متاح كنقطة انطلاق — خصصه لسوقك، أضف علامتك التجارية، وأطلق متجرك.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

بناء تطبيق Full-Stack باستخدام Strapi 5 و Next.js 15 App Router
تعلّم كيفية بناء تطبيق متكامل باستخدام Strapi 5 كنظام إدارة محتوى headless و Next.js 15 App Router للواجهة الأمامية. يغطي هذا الدليل نمذجة المحتوى، واجهات REST API، والعرض من جانب الخادم، والنشر في بيئة الإنتاج.

بناء واجهة GraphQL آمنة الأنواع مع Next.js App Router و Yoga و Pothos
تعلم كيفية بناء واجهة GraphQL API آمنة الأنواع بالكامل باستخدام Next.js 15 App Router و GraphQL Yoga و Pothos schema builder. يغطي هذا الدليل العملي تصميم المخططات والاستعلامات والتحولات والمصادقة وعميل React باستخدام urql.

بناء موقع محتوى متكامل باستخدام Payload CMS 3 و Next.js
تعلّم كيفية بناء موقع محتوى كامل الميزات باستخدام Payload CMS 3 الذي يعمل مباشرة داخل Next.js App Router. يغطي هذا الدرس المجموعات، محرر النصوص الغنية، رفع الوسائط، المصادقة، والنشر في بيئة الإنتاج.