بناء مشروع SaaS متكامل باستخدام Next.js 15 و Stripe و Auth.js v5

Noqta TeamAI Bot
بواسطة Noqta Team & AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

ابنِ نظام فوترة SaaS خاص بك من الصفر. يرشدك هذا الدليل خطوة بخطوة لإنشاء تطبيق اشتراكات متكامل باستخدام Next.js 15 Server Actions و Stripe Checkout وبوابة العملاء و Auth.js v5 — كل ما تحتاجه لإطلاق منتج SaaS حقيقي.

ما ستتعلمه

بنهاية هذا الدليل، ستتمكن من:

  • إعداد Auth.js v5 مع مزودي GitHub و Google OAuth
  • دمج Stripe Checkout لفوترة الاشتراكات
  • بناء صفحة أسعار بعدة مستويات
  • معالجة Stripe webhooks لمزامنة حالة الاشتراك
  • إنشاء مسارات محمية باستخدام middleware وفحص الجلسات
  • تطبيق بوابة عملاء لإدارة الاشتراكات
  • تخزين بيانات المستخدمين والاشتراكات باستخدام Drizzle ORM و PostgreSQL

المتطلبات المسبقة

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت
  • حساب Stripe (مجاني الإنشاء على stripe.com)
  • تطبيق GitHub OAuth أو مشروع Google Cloud للمصادقة
  • PostgreSQL يعمل محلياً (أو مستضاف مثل Neon أو Supabase)
  • معرفة أساسية بـ Next.js و TypeScript

نظرة عامة على البنية

┌─────────────────────────────────────────────┐
│                  Next.js 15                  │
│                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  │
│  │ Auth.js  │  │  Stripe  │  │  Drizzle  │  │
│  │   v5     │  │ Checkout │  │    ORM    │  │
│  └────┬─────┘  └────┬─────┘  └─────┬─────┘  │
│       │              │              │         │
│  ┌────▼──────────────▼──────────────▼─────┐  │
│  │           Server Actions               │  │
│  └────────────────────────────────────────┘  │
└──────────────────┬───────────────────────────┘
                   │
         ┌─────────▼─────────┐
         │    PostgreSQL      │
         │  (Users, Subs)     │
         └───────────────────┘

الخطوة 1: إنشاء مشروع Next.js

ابدأ بإنشاء تطبيق Next.js 15 جديد مع TypeScript و App Router:

npx create-next-app@latest saas-starter --typescript --tailwind --eslint --app --src-dir
cd saas-starter

ثبّت التبعيات المطلوبة:

npm install next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless stripe
npm install -D drizzle-kit dotenv

أنشئ ملف .env.local بمتغيرات البيئة المطلوبة:

# قاعدة البيانات
DATABASE_URL="postgresql://user:password@localhost:5432/saas_starter"
 
# Auth.js
AUTH_SECRET="run-npx-auth-secret-to-generate"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
 
# Stripe
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
 
# التطبيق
NEXT_PUBLIC_APP_URL="http://localhost:3000"

أنشئ AUTH_SECRET بتنفيذ npx auth secret في الطرفية. لا تقم أبداً بحفظ هذه القيمة في نظام التحكم بالإصدارات.

الخطوة 2: إعداد مخطط قاعدة البيانات مع Drizzle ORM

أنشئ مخطط قاعدة البيانات الذي يتعامل مع جلسات Auth.js واشتراكات Stripe.

أنشئ src/db/schema.ts:

import {
  pgTable,
  text,
  timestamp,
  integer,
  primaryKey,
  boolean,
} from "drizzle-orm/pg-core";
import type { AdapterAccountType } from "next-auth/adapters";
 
// جداول Auth.js المطلوبة
export const users = pgTable("users", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").unique(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
  stripeCustomerId: text("stripe_customer_id").unique(),
  stripePriceId: text("stripe_price_id"),
  stripeSubscriptionId: text("stripe_subscription_id").unique(),
  stripeSubscriptionStatus: text("stripe_subscription_status"),
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
});
 
export const accounts = pgTable(
  "accounts",
  {
    userId: text("userId")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccountType>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => [
    primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  ]
);
 
export const sessions = pgTable("sessions", {
  sessionToken: text("sessionToken").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
});
 
export const verificationTokens = pgTable(
  "verificationTokens",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: timestamp("expires", { mode: "date" }).notNull(),
  },
  (verificationToken) => [
    primaryKey({
      columns: [verificationToken.identifier, verificationToken.token],
    }),
  ]
);

أنشئ src/db/index.ts للاتصال بقاعدة البيانات:

import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

أنشئ drizzle.config.ts في جذر المشروع:

import { defineConfig } from "drizzle-kit";
import dotenv from "dotenv";
 
dotenv.config({ path: ".env.local" });
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

نفّذ الترحيل:

npx drizzle-kit push

الخطوة 3: إعداد Auth.js v5

أنشئ src/auth.ts:

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub, Google],
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
});

أنشئ مسار API في src/app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth";
 
export const { GET, POST } = handlers;

أنشئ صفحة تسجيل الدخول في src/app/login/page.tsx:

import { signIn } from "@/auth";
 
export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm space-y-6 rounded-xl border p-8 shadow-sm">
        <div className="text-center">
          <h1 className="text-2xl font-bold">مرحباً بعودتك</h1>
          <p className="mt-2 text-sm text-gray-500">
            سجّل دخولك لحسابك للمتابعة
          </p>
        </div>
 
        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            المتابعة عبر GitHub
          </button>
        </form>
 
        <form
          action={async () => {
            "use server";
            await signIn("google", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-gray-50"
          >
            المتابعة عبر Google
          </button>
        </form>
      </div>
    </div>
  );
}

الخطوة 4: حماية المسارات باستخدام Middleware

أنشئ src/middleware.ts لحماية المسارات المصادق عليها:

import { auth } from "@/auth";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const isOnLogin = req.nextUrl.pathname === "/login";
 
  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl));
  }
 
  if (isOnLogin && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }
});
 
export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

الخطوة 5: إعداد منتجات وأسعار Stripe

قبل كتابة الكود، أنشئ المنتجات في لوحة تحكم Stripe أو باستخدام Stripe CLI:

# تثبيت Stripe CLI
brew install stripe/stripe-cli/stripe
 
# تسجيل الدخول لحساب Stripe
stripe login
 
# إنشاء المنتجات والأسعار
stripe products create --name="Starter" --description="للأفراد والمشاريع الصغيرة"
stripe prices create --product=prod_xxx --unit-amount=900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Pro" --description="للفرق والأعمال المتنامية"
stripe prices create --product=prod_yyy --unit-amount=2900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Enterprise" --description="للمؤسسات الكبيرة"
stripe prices create --product=prod_zzz --unit-amount=9900 --currency=usd --recurring[interval]=month

أنشئ src/lib/stripe.ts:

import Stripe from "stripe";
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-12-18.acacia",
  typescript: true,
});
 
export const PLANS = [
  {
    name: "Starter",
    description: "للأفراد والمشاريع الصغيرة",
    price: "$9",
    priceId: process.env.STRIPE_STARTER_PRICE_ID!,
    features: [
      "حتى 3 مشاريع",
      "تحليلات أساسية",
      "دعم عبر البريد",
      "1 جيجابايت تخزين",
    ],
  },
  {
    name: "Pro",
    description: "للفرق والأعمال المتنامية",
    price: "$29",
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    popular: true,
    features: [
      "مشاريع غير محدودة",
      "تحليلات متقدمة",
      "دعم أولوية",
      "10 جيجابايت تخزين",
      "تعاون الفريق",
      "تكاملات مخصصة",
    ],
  },
  {
    name: "Enterprise",
    description: "للمؤسسات الكبيرة",
    price: "$99",
    priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
    features: [
      "كل ميزات Pro",
      "تخزين غير محدود",
      "دعم مخصص",
      "SSO و SAML",
      "عقود مخصصة",
      "ضمانات SLA",
    ],
  },
] as const;

الخطوة 6: بناء صفحة الأسعار

أنشئ src/app/pricing/page.tsx:

import { auth } from "@/auth";
import { PLANS } from "@/lib/stripe";
import { CheckoutButton } from "./checkout-button";
 
export default async function PricingPage() {
  const session = await auth();
 
  return (
    <div className="mx-auto max-w-5xl px-4 py-16">
      <div className="text-center">
        <h1 className="text-4xl font-bold tracking-tight">
          أسعار بسيطة وشفافة
        </h1>
        <p className="mt-4 text-lg text-gray-500">
          اختر الخطة التي تناسب احتياجاتك. يمكنك الترقية أو التخفيض في أي وقت.
        </p>
      </div>
 
      <div className="mt-16 grid gap-8 md:grid-cols-3">
        {PLANS.map((plan) => (
          <div
            key={plan.name}
            className={`relative rounded-2xl border p-8 shadow-sm ${
              plan.popular
                ? "border-blue-600 ring-2 ring-blue-600"
                : "border-gray-200"
            }`}
          >
            {plan.popular && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
                الأكثر شيوعاً
              </span>
            )}
 
            <h3 className="text-lg font-semibold">{plan.name}</h3>
            <p className="mt-1 text-sm text-gray-500">{plan.description}</p>
 
            <p className="mt-6">
              <span className="text-4xl font-bold">{plan.price}</span>
              <span className="text-gray-500">/شهرياً</span>
            </p>
 
            <CheckoutButton
              priceId={plan.priceId}
              isLoggedIn={!!session}
              popular={plan.popular}
            />
 
            <ul className="mt-8 space-y-3">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2 text-sm">
                  <svg
                    className="h-4 w-4 text-green-500"
                    fill="none"
                    viewBox="0 0 24 24"
                    strokeWidth={2}
                    stroke="currentColor"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      d="M4.5 12.75l6 6 9-13.5"
                    />
                  </svg>
                  {feature}
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>
    </div>
  );
}

أنشئ زر الدفع كمكون عميل في src/app/pricing/checkout-button.tsx:

"use client";
 
import { useTransition } from "react";
import { createCheckoutSession } from "./actions";
 
interface CheckoutButtonProps {
  priceId: string;
  isLoggedIn: boolean;
  popular?: boolean;
}
 
export function CheckoutButton({
  priceId,
  isLoggedIn,
  popular,
}: CheckoutButtonProps) {
  const [isPending, startTransition] = useTransition();
 
  const handleCheckout = () => {
    startTransition(async () => {
      const { url } = await createCheckoutSession(priceId);
      if (url) window.location.href = url;
    });
  };
 
  return (
    <button
      onClick={handleCheckout}
      disabled={isPending}
      className={`mt-6 w-full rounded-lg px-4 py-2.5 text-sm font-semibold ${
        popular
          ? "bg-blue-600 text-white hover:bg-blue-700"
          : "bg-gray-900 text-white hover:bg-gray-800"
      } disabled:opacity-50`}
    >
      {isPending ? "جارٍ التحميل..." : isLoggedIn ? "اشترك الآن" : "ابدأ الآن"}
    </button>
  );
}

الخطوة 7: إنشاء Server Actions للدفع

أنشئ src/app/pricing/actions.ts:

"use server";
 
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
 
export async function createCheckoutSession(priceId: string) {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  // الحصول على أو إنشاء عميل Stripe
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  let customerId = user.stripeCustomerId;
 
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      name: user.name ?? undefined,
      metadata: { userId: user.id },
    });
 
    await db
      .update(users)
      .set({ stripeCustomerId: customer.id })
      .where(eq(users.id, user.id));
 
    customerId = customer.id;
  }
 
  // إنشاء جلسة الدفع
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
  });
 
  return { url: checkoutSession.url };
}
 
export async function createCustomerPortalSession() {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  if (!user.stripeCustomerId) {
    redirect("/pricing");
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });
 
  return { url: portalSession.url };
}

الخطوة 8: معالجة Stripe Webhooks

هذا هو الجزء الأهم. تحافظ webhooks من Stripe على مزامنة قاعدة بياناتك مع تغييرات الاشتراكات.

أنشئ src/app/api/webhooks/stripe/route.ts:

import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
 
export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature")!;
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("فشل التحقق من توقيع Webhook:", err);
    return new Response("Webhook Error", { status: 400 });
  }
 
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
 
      const userId = subscription.metadata.userId;
 
      await db
        .update(users)
        .set({
          stripeSubscriptionId: subscription.id,
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.id, userId));
 
      break;
    }
 
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
 
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: null,
          stripeSubscriptionId: null,
          stripeSubscriptionStatus: "canceled",
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
  }
 
  return new Response("OK", { status: 200 });
}

لاختبار Webhooks محلياً، استخدم Stripe CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

أبقِ Stripe CLI يعمل في طرفية منفصلة أثناء التطوير. سيقوم بتوجيه أحداث webhook إلى خادمك المحلي ويعطيك مفتاح توقيع webhook لاستخدامه في .env.local.

الخطوة 9: بناء لوحة التحكم

أنشئ لوحة التحكم المحمية في src/app/dashboard/page.tsx:

import { auth, signOut } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { PLANS } from "@/lib/stripe";
import { ManageSubscriptionButton } from "./manage-subscription-button";
 
export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  const currentPlan = PLANS.find((p) => p.priceId === user.stripePriceId);
  const isActive = user.stripeSubscriptionStatus === "active";
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-16">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">لوحة التحكم</h1>
          <p className="text-gray-500">مرحباً بعودتك، {session.user.name}</p>
        </div>
        <form
          action={async () => {
            "use server";
            await signOut({ redirectTo: "/" });
          }}
        >
          <button
            type="submit"
            className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"
          >
            تسجيل الخروج
          </button>
        </form>
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">الاشتراك</h2>
 
        {isActive && currentPlan ? (
          <div className="mt-4 space-y-4">
            <div className="flex items-center gap-3">
              <span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">
                نشط
              </span>
              <span className="font-medium">خطة {currentPlan.name}</span>
              <span className="text-gray-500">
                {currentPlan.price}/شهرياً
              </span>
            </div>
            <ManageSubscriptionButton />
          </div>
        ) : (
          <div className="mt-4 space-y-4">
            <p className="text-gray-500">
              أنت حالياً على المستوى المجاني.
            </p>
            <a
              href="/pricing"
              className="inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
            >
              ترقية الآن
            </a>
          </div>
        )}
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">الحساب</h2>
        <div className="mt-4 space-y-2 text-sm">
          <p>
            <span className="text-gray-500">البريد الإلكتروني:</span> {user.email}
          </p>
          <p>
            <span className="text-gray-500">عضو منذ:</span>{" "}
            {user.createdAt?.toLocaleDateString("ar-SA")}
          </p>
        </div>
      </div>
    </div>
  );
}

أنشئ src/app/dashboard/manage-subscription-button.tsx:

"use client";
 
import { useTransition } from "react";
import { createCustomerPortalSession } from "../pricing/actions";
 
export function ManageSubscriptionButton() {
  const [isPending, startTransition] = useTransition();
 
  return (
    <button
      onClick={() => {
        startTransition(async () => {
          const { url } = await createCustomerPortalSession();
          if (url) window.location.href = url;
        });
      }}
      disabled={isPending}
      className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
    >
      {isPending ? "جارٍ التحميل..." : "إدارة الاشتراك"}
    </button>
  );
}

الخطوة 10: إضافة فحوصات الاشتراك لواجهة API

أنشئ مساعداً قابلاً لإعادة الاستخدام في src/lib/subscription.ts لتقييد الميزات حسب الخطة:

import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
 
export async function getUserSubscription() {
  const session = await auth();
  if (!session?.user?.id) return null;
 
  const [user] = await db
    .select({
      stripePriceId: users.stripePriceId,
      stripeSubscriptionStatus: users.stripeSubscriptionStatus,
    })
    .from(users)
    .where(eq(users.id, session.user.id));
 
  return {
    isActive: user?.stripeSubscriptionStatus === "active",
    priceId: user?.stripePriceId,
  };
}
 
export async function requireSubscription() {
  const sub = await getUserSubscription();
  if (!sub?.isActive) {
    throw new Error("يتطلب اشتراكاً نشطاً");
  }
  return sub;
}

استخدمه في أي Server Action أو مسار API:

"use server";
 
import { requireSubscription } from "@/lib/subscription";
 
export async function premiumFeatureAction() {
  await requireSubscription(); // يرمي خطأ إذا لم يكن مشتركاً
 
  // منطق الميزة المميزة هنا
}

اختبار التطبيق

1. اختبار تدفق المصادقة

  1. شغّل خادم التطوير: npm run dev
  2. زُر http://localhost:3000/login
  3. سجّل الدخول عبر GitHub أو Google
  4. تحقق من إعادة التوجيه إلى /dashboard

2. اختبار تدفق الاشتراك

  1. زُر /pricing واختر خطة
  2. استخدم بطاقة Stripe التجريبية: 4242 4242 4242 4242 (أي تاريخ انتهاء مستقبلي، أي CVC)
  3. أكمل الدفع وتحقق من ظهور خطتك النشطة في لوحة التحكم

3. اختبار Webhooks محلياً

# الطرفية 1: تشغيل Next.js
npm run dev
 
# الطرفية 2: توجيه أحداث Stripe
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# الطرفية 3: تشغيل حدث تجريبي
stripe trigger checkout.session.completed

استكشاف الأخطاء وإصلاحها

خطأ "No matching state found" أثناء OAuth

هذا يعني عادة أن AUTH_SECRET مفقود أو تغيّر. أعد توليده بـ npx auth secret.

Webhooks لا تصل لخادمك المحلي

تأكد من تشغيل Stripe CLI بأمر stripe listen. تحقق من تطابق STRIPE_WEBHOOK_SECRET في .env.local مع المفتاح الذي يعرضه CLI.

حالة الاشتراك لا تتحدث

تحقق من سجلات webhook في لوحة تحكم Stripe تحت Developers > Webhooks. تأكد من صحة رابط webhook ومفتاح التوقيع.

النشر في الإنتاج

عند النشر في بيئة الإنتاج، تذكر:

  1. إعداد نقطة نهاية Stripe webhook في لوحة تحكم Stripe تشير إلى https://your-domain.com/api/webhooks/stripe
  2. التبديل لمفاتيح Stripe الحية — استبدل sk_test_ و pk_test_ بمفاتيحك الحية
  3. إعداد روابط إعادة توجيه OAuth في GitHub/Google لنطاقك الإنتاجي
  4. تعيين جميع متغيرات البيئة في منصة الاستضافة (Vercel، Railway، إلخ)
  5. تفعيل بوابة عملاء Stripe في لوحة تحكم Stripe تحت Settings > Billing > Customer Portal

الخطوات التالية

الآن بعد أن لديك نظام فوترة SaaS يعمل، إليك بعض الأفكار للتوسع:

  • إضافة إشعارات بريدية باستخدام Resend أو SendGrid لأحداث الاشتراك
  • تطبيق فوترة حسب الاستخدام مع Stripe metered billing للمنتجات المبنية على API
  • إضافة إدارة الفرق مع دعم المؤسسات والتحكم بالصلاحيات
  • بناء لوحة تحكم إدارية لعرض مقاييس الإيرادات وبيانات العملاء
  • إضافة فترة تجريبية مجانية باستخدام معامل trial_period_days في Stripe

الخلاصة

لقد بنيت مجموعة أدوات SaaS متكاملة باستخدام Next.js 15 و Auth.js v5 و Stripe. يمنحك هذا الأساس مصادقة OAuth وفوترة اشتراكات بعدة مستويات وإدارة حالة مبنية على webhooks وبوابة خدمة ذاتية للعملاء. من هنا، يمكنك التركيز على بناء الميزات الفعلية لمنتج SaaS الخاص بك، مع العلم أن بنية الفوترة التحتية متينة.

الأنماط المستخدمة في هذا الدليل — Server Actions للتعديلات، middleware لحماية المسارات، و webhooks لمزامنة الحالة — تمثل نهج Next.js الحديث الذي يتدرج بشكل جيد من المشاريع الجانبية إلى تطبيقات الإنتاج.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على إنشاء أول إضافة Airtable الخاصة بك: دليل خطوة بخطوة للوظائف المخصصة.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

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

30 د قراءة·