diff --git a/.dockerignore b/.dockerignore
index 913f304..704908b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,6 @@
.cursor
.claude
+.conductor
.kiro
.github
.next
diff --git a/.gitignore b/.gitignore
index c5900c6..f81e144 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,9 @@ certificates
# claude code
.claude
+# conductor
+.conductor
+
# kiro
.kiro
diff --git a/biome.json b/biome.json
index bcb08bb..75868e3 100644
--- a/biome.json
+++ b/biome.json
@@ -14,6 +14,7 @@
".cursor/**",
".claude/**",
".kiro/**",
+ ".conductor/**",
".vscode/**",
".source/**",
"node_modules/**",
@@ -77,6 +78,7 @@
".wrangler/**",
".cursor/**",
".claude/**",
+ ".conductor/**",
".kiro/**",
".vscode/**",
".source/**",
diff --git a/content/blog/premium.mdx b/content/blog/premium.mdx
new file mode 100644
index 0000000..4361376
--- /dev/null
+++ b/content/blog/premium.mdx
@@ -0,0 +1,56 @@
+---
+title: "Premium Blog Post"
+description: "This blog post is a test for premium content."
+date: "2025-08-30"
+published: true
+premium: true
+categories: ["product"]
+author: "fox"
+image: "/images/blog/post-7.png"
+---
+
+This blog post is a test for premium content.
+
+You can read this part of the blog post if you are not a premium user.
+
+But for the rest of the blog post, you need to be logged in as a premium user.
+
+You can click the "Sign In" button to sign in as a user with free plan.
+
+Then you can click the "Upgrade Now" button to upgrade to a premium plan.
+
+
+ Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe.
+
+
+You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan.
+
+```
+Card number: 4242 4242 4242 4242
+Exp: 12/34
+CVV: 567
+```
+
+After that, you can return to the blog post and you can read the rest of the blog post.
+
+For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog).
+
+Now the rest of the blog post is premium content.
+
+
+
+
+ This is the beginning of the premium content part.
+
+
+This is the premium content part.
+
+You can read this paragraph only if you are a premium user.
+
+Please don't share this blog post with others.
+
+
+ This is the end of the premium content part.
+
+
+
diff --git a/content/blog/premium.zh.mdx b/content/blog/premium.zh.mdx
new file mode 100644
index 0000000..a80ac60
--- /dev/null
+++ b/content/blog/premium.zh.mdx
@@ -0,0 +1,56 @@
+---
+title: "测试专用付费文章"
+description: "这是一篇测试专用付费文章。"
+date: "2025-08-30"
+published: true
+premium: true
+categories: ["product"]
+author: "fox"
+image: "/images/blog/post-7.png"
+---
+
+这是一篇测试专用的付费文章。
+
+如果你不是付费用户,你可以阅读这篇文章的这部分内容。
+
+但如果你想阅读剩下的内容,你需要成为一个付费用户。
+
+你可以点击 "登录" 按钮来以免费用户的身份登录。
+
+然后你可以点击 "立即升级" 按钮来升级到付费计划。
+
+
+ 不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
+
+
+你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
+
+```
+Card number: 4242 4242 4242 4242
+Exp: 12/34
+CVV: 567
+```
+
+之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
+
+更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
+
+现在剩下的内容是付费内容。
+
+
+
+
+ 这是付费内容部分的开始。
+
+
+这是付费内容部分。
+
+你可以阅读这篇内容,只要你是一个付费用户。
+
+请不要分享这篇文章给其他人。
+
+
+ 这是付费内容部分的结束。
+
+
+
diff --git a/messages/en.json b/messages/en.json
index cbd0f4d..8b32a7e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5,6 +5,7 @@
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
},
"Common": {
+ "premium": "Premium",
"login": "Log in",
"logout": "Log out",
"signUp": "Sign up",
@@ -281,7 +282,20 @@
"all": "All",
"noPostsFound": "No posts found",
"allPosts": "All Posts",
- "morePosts": "More Posts"
+ "morePosts": "More Posts",
+ "premiumContent": {
+ "title": "Unlock Premium Content",
+ "description": "Subscribe to our Pro plan to access all premium articles and exclusive content.",
+ "upgradeCta": "Upgrade Now",
+ "benefit1": "All premium articles",
+ "benefit2": "Exclusive content",
+ "benefit3": "Cancel anytime",
+ "signIn": "Sign In",
+ "loginRequired": "Sign in to continue reading",
+ "loginDescription": "This is a premium article. Sign in to your account to access the full content.",
+ "checkingAccess": "Checking access...",
+ "loadingContent": "Loading full content..."
+ }
},
"DocsPage": {
"toc": "Table of Contents",
diff --git a/messages/zh.json b/messages/zh.json
index ffb9f34..9a23698 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -5,6 +5,7 @@
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
},
"Common": {
+ "premium": "付费文章",
"login": "登录",
"logout": "退出",
"signUp": "注册",
@@ -281,7 +282,20 @@
"all": "全部",
"noPostsFound": "没有找到文章",
"allPosts": "全部文章",
- "morePosts": "更多文章"
+ "morePosts": "更多文章",
+ "premiumContent": {
+ "title": "解锁付费内容",
+ "description": "订阅我们的付费计划,访问所有付费文章和独家内容。",
+ "upgradeCta": "立即升级",
+ "benefit1": "所有文章",
+ "benefit2": "独家内容",
+ "benefit3": "随时取消",
+ "signIn": "登录",
+ "loginRequired": "登录以继续阅读",
+ "loginDescription": "这是一篇付费文章,请登录您的账户以访问完整内容。",
+ "checkingAccess": "检查阅读权限...",
+ "loadingContent": "加载完整内容..."
+ }
},
"DocsPage": {
"toc": "目录",
diff --git a/source.config.ts b/source.config.ts
index 7917344..1243d2d 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -85,7 +85,7 @@ export const category = defineCollections({
/**
* Blog posts
*
- * dtitle is required, but description is optional in frontmatter
+ * title is required, but description is optional in frontmatter
*/
export const blog = defineCollections({
type: 'doc',
@@ -94,6 +94,7 @@ export const blog = defineCollections({
image: z.string(),
date: z.string().date(),
published: z.boolean().default(true),
+ premium: z.boolean().optional(),
categories: z.array(z.string()),
author: z.string(),
}),
diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx
index bec8bde..f97e99e 100644
--- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx
@@ -1,11 +1,15 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import BlogGrid from '@/components/blog/blog-grid';
+import { PremiumBadge } from '@/components/blog/premium-badge';
+import { PremiumGuard } from '@/components/blog/premium-guard';
import { getMDXComponents } from '@/components/docs/mdx-components';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
+import { checkPremiumAccess } from '@/lib/premium-access';
+import { getSession } from '@/lib/server';
import {
type BlogType,
authorSource,
@@ -13,6 +17,7 @@ import {
categorySource,
} from '@/lib/source';
import { getUrlWithLocale } from '@/lib/urls/urls';
+import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { CalendarIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
@@ -21,7 +26,6 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import '@/styles/mdx.css';
-import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
/**
* get related posts, random pick from all posts with same locale, different slug,
@@ -83,7 +87,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
notFound();
}
- const { date, title, description, image, author, categories } = post.data;
+ const { date, title, description, image, author, categories, premium } =
+ post.data;
const publishDate = formatDate(new Date(date));
const blogAuthor = authorSource.getPage([author], locale);
@@ -91,6 +96,13 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
.getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? ''));
+ // Check premium access for premium posts
+ const session = await getSession();
+ const hasPremiumAccess =
+ premium && session?.user?.id
+ ? await checkPremiumAccess(session.user.id)
+ : !premium; // Non-premium posts are always accessible
+
const MDX = post.data.body;
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
@@ -121,7 +133,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
)}
- {/* blog post date */}
+ {/* blog post date and premium badge */}
{/* blog post title */}
@@ -141,8 +155,14 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{/* blog post content */}
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
-
-
+
+
+
+
diff --git a/src/components/blog/blog-card.tsx b/src/components/blog/blog-card.tsx
index 7d587c9..23eff59 100644
--- a/src/components/blog/blog-card.tsx
+++ b/src/components/blog/blog-card.tsx
@@ -4,6 +4,7 @@ import { formatDate } from '@/lib/formatter';
import { type BlogType, authorSource, categorySource } from '@/lib/source';
import Image from 'next/image';
import BlogImage from './blog-image';
+import { PremiumBadge } from './premium-badge';
interface BlogCardProps {
locale: string;
@@ -30,6 +31,13 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
title={title || 'image for blog post'}
/>
+ {/* Premium badge - top right */}
+ {post.data.premium && (
+
diff --git a/src/components/blog/premium-badge.tsx b/src/components/blog/premium-badge.tsx
new file mode 100644
index 0000000..cff5f75
--- /dev/null
+++ b/src/components/blog/premium-badge.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+import { CrownIcon } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+
+interface PremiumBadgeProps {
+ className?: string;
+ variant?: 'default' | 'outline' | 'secondary';
+ size?: 'sm' | 'default' | 'lg';
+}
+
+export function PremiumBadge({
+ className,
+ variant = 'default',
+ size = 'default',
+}: PremiumBadgeProps) {
+ const t = useTranslations('Common');
+
+ const sizeClasses = {
+ sm: 'text-xs h-5',
+ default: 'text-xs h-6',
+ lg: 'text-sm h-7',
+ };
+
+ const iconSizes = {
+ sm: 'size-3',
+ default: 'size-3',
+ lg: 'size-4',
+ };
+
+ return (
+
+
+ {t('premium')}
+
+ );
+}
diff --git a/src/components/blog/premium-content.tsx b/src/components/blog/premium-content.tsx
new file mode 100644
index 0000000..e56fb1e
--- /dev/null
+++ b/src/components/blog/premium-content.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useCurrentUser } from '@/hooks/use-current-user';
+import { useCurrentPlan } from '@/hooks/use-payment';
+import type { ReactNode } from 'react';
+
+interface PremiumContentProps {
+ children: ReactNode;
+}
+
+/**
+ * Client-side Premium Content component
+ * Note: This component now serves as a fallback for client-side rendering.
+ * The main security filtering happens server-side in PremiumGuard component.
+ */
+export function PremiumContent({ children }: PremiumContentProps) {
+ const currentUser = useCurrentUser();
+ const { data: paymentData } = useCurrentPlan(currentUser?.id);
+
+ // Determine if user has premium access
+ const hasPremiumAccess =
+ paymentData?.currentPlan &&
+ (paymentData.currentPlan.isLifetime || !paymentData.currentPlan.isFree);
+
+ // Only show content if user has premium access
+ // This is a client-side fallback - main security is handled server-side
+ if (!currentUser || !hasPremiumAccess) {
+ return null;
+ }
+
+ return
{children}
;
+}
diff --git a/src/components/blog/premium-guard.tsx b/src/components/blog/premium-guard.tsx
new file mode 100644
index 0000000..9462dcc
--- /dev/null
+++ b/src/components/blog/premium-guard.tsx
@@ -0,0 +1,228 @@
+'use client';
+
+import { LoginWrapper } from '@/components/auth/login-wrapper';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { useCurrentUser } from '@/hooks/use-current-user';
+import { useCurrentPlan } from '@/hooks/use-payment';
+import { LocaleLink, useLocalePathname } from '@/i18n/navigation';
+import {
+ ArrowRightIcon,
+ CheckCircleIcon,
+ CrownIcon,
+ Loader2Icon,
+ LockIcon,
+} from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import type { ReactNode } from 'react';
+
+interface PremiumGuardProps {
+ children: ReactNode;
+ isPremium: boolean;
+ canAccess?: boolean;
+ className?: string;
+}
+
+export function PremiumGuard({
+ children,
+ isPremium,
+ canAccess,
+ className,
+}: PremiumGuardProps) {
+ // All hooks must be called unconditionally at the top
+ const t = useTranslations('BlogPage');
+ const pathname = useLocalePathname();
+ const currentUser = useCurrentUser();
+ const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
+ currentUser?.id
+ );
+
+ // For non-premium articles, show content immediately with no extra processing
+ if (!isPremium) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ // Determine if user has premium access
+ const hasPremiumAccess =
+ paymentData?.currentPlan &&
+ (!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
+
+ // If server-side check has already determined access, use that
+ if (canAccess !== undefined) {
+ // Server has determined the user has access
+ if (canAccess) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ // Server determined no access, show appropriate message
+ if (!currentUser) {
+ return (
+
+
+ {/* Show partial content before protection */}
+ {children}
+