Saasfly is an open-source Next.js boilerplate with 2,800+ GitHub stars, positioning itself as "you don't need to buy templates anymore." It uses a modern monorepo structure with Turborepo, tRPC, Prisma, and Stripe.
We ran it through a comprehensive PLG skills audit — analyzing signup flow CRO, feature gating, trial optimization, product analytics, self-serve motion, and paywall design.
Overall PLG score: 4/10 — PostHog analytics and full Stripe checkout are implemented, but the critical flaw is that feature limits exist in the UI but aren't enforced in code — the gating logic is commented out.
The full audit
| Category | Score | Key finding |
|---|---|---|
| Signup flow | 7/10 | Magic link + GitHub OAuth, multi-language |
| Feature gating | 2/10 | Plan limits defined but NOT enforced |
| Trial optimization | 0/10 | No trial system implemented |
| Product analytics | 3/10 | PostHog integrated, pageviews only |
| Self-serve motion | 8/10 | Full Stripe checkout + billing portal |
| Paywall/upgrade CRO | 4/10 | Clean pricing page, but no in-app prompts |
1. Signup flow analysis
What's implemented
Magic link authentication with differentiated emails for new vs existing users:
// packages/auth/nextauth.ts
EmailProvider({
sendVerificationRequest: async ({ identifier, url }) => {
const user = await db
.selectFrom("User")
.select(["name", "emailVerified"])
.where("email", "=", identifier)
.executeTakeFirst();
const userVerified = !!user?.emailVerified;
const authSubject = userVerified
? `Sign-in link for ${siteConfig.name}`
: "Activate your account";
Simple auth form with email-only field:
// apps/nextjs/src/components/user-auth-form.tsx
const userAuthSchema = z.object({
email: z.string().email(),
});
Form fields: 1 (email only for magic link)
Authentication options:
- Magic link (email)
- GitHub OAuth
Multi-language support: English, Japanese, Korean, Chinese
What's missing
- No password-based authentication
- No Google OAuth (only GitHub)
- No signup analytics tracking
- No progressive profiling
2. Feature gating audit
What's implemented
Three subscription tiers defined in the database:
// packages/db/prisma/schema.prisma
enum SubscriptionPlan {
FREE
PRO
BUSINESS
}
model Customer {
plan SubscriptionPlan?
}
model K8sClusterConfig {
plan SubscriptionPlan? @default(FREE)
}
Pricing data with clear feature limits:
// apps/nextjs/src/config/price/price-data.ts
{
id: "starter",
benefits: [
"Up to 1 cluster per month", // Limit defined
"Basic analytics and reporting",
],
},
{
id: "pro",
benefits: [
"Up to 3 clusters per month", // Higher limit
"Advanced analytics",
],
},
{
id: "business",
benefits: [
"Up to 10 clusters per month", // Highest limit
],
}
The critical problem
Feature limits are NOT enforced. Look at the cluster creation code:
// packages/api/src/router/k8s.ts
createCluster: protectedProcedure
.input(k8sClusterCreateSchema)
.mutation(async ({ ctx, input }) => {
// Creates cluster WITHOUT checking plan limits
const newCluster = await db
.insertInto("K8sClusterConfig")
.values({
name: input.name,
plan: SubscriptionPlan.FREE, // Always FREE, no limit check
authUserId: userId,
})
.returning("id")
.executeTakeFirst();
return { id: newCluster.id, success: true };
}),
And the UI upgrade prompt is commented out:
// apps/nextjs/src/components/k8s/cluster-create-button.tsx
async function onClick() {
const res = await trpc.k8s.createCluster.mutate({...});
// COMMENTED OUT:
// if (response.status === 402) {
// return toast({
// title: "Limit of 1 cluster reached.",
// description: "Please upgrade to the PROD plan.",
// variant: "destructive",
// });
// }
}
Impact: A FREE user can create unlimited clusters. The pricing page promises limits that don't exist.
3. Trial optimization
What's implemented
Nothing.
What's missing
Trials are completely absent:
- No
trial_ends_atfield in Customer model - No trial period in Stripe checkout
- No trial expiration emails
- No trial countdown UI
- FREE plan exists but isn't positioned as a trial
4. Product analytics
What's implemented
PostHog SDK with automatic pageview tracking:
// apps/nextjs/src/config/providers.tsx
import posthog from "posthog-js";
export function PostHogPageview() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (pathname) {
let url = window.origin + pathname;
if (searchParams?.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture("$pageview", {
$current_url: url,
});
}
}, [pathname, searchParams]);
}
Environment variables configured:
NEXT_PUBLIC_POSTHOG_KEYNEXT_PUBLIC_POSTHOG_HOST
What's missing
Only pageviews are tracked. Searching the entire codebase:
grep -r "posthog.capture" --include="*.ts" --include="*.tsx"
# Returns: 1 result (the pageview above)
No custom events for:
- Signup completed
- Cluster creation
- Upgrade started
- Payment completed
- Feature usage
5. Self-serve motion
What's implemented
Full Stripe checkout flow:
// packages/api/src/router/stripe.ts
createSession: protectedProcedure
.input(z.object({ planId: z.string() }))
.mutation(async (opts) => {
const customer = await db
.selectFrom("Customer")
.where("authUserId", "=", userId)
.executeTakeFirst();
if (customer && customer.plan !== "FREE") {
// Existing subscriber → billing portal
const session = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId!,
return_url: returnUrl,
});
return { success: true, url: session.url };
}
// New subscriber → checkout
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: email,
line_items: [{ price: planId, quantity: 1 }],
success_url: returnUrl,
});
return { success: true, url: session.url };
}),
Webhook handling for subscription events:
// packages/stripe/src/webhooks.ts
if (event.type === "checkout.session.completed") {
// Update customer with Stripe IDs
await db.updateTable("Customer")
.set({
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
})
.execute();
}
if (event.type === "invoice.payment_succeeded") {
// Update plan and period end date
const plan = getSubscriptionPlan(priceId);
await db.updateTable("Customer")
.set({
plan: plan || SubscriptionPlan.FREE,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
})
.execute();
}
Smart upgrade button:
// apps/nextjs/src/components/price/billing-form-button.tsx
<Button onClick={stripeSessionAction}>
{subscriptionPlan.stripePriceId
? dict.manage_subscription // Existing subscriber
: dict.upgrade} // Free user
</Button>
What's missing
- No coupon/discount code support
- No dunning for failed payments
- No billing history page
- No payment method management UI (only via Stripe portal)
6. Paywall and upgrade CRO
What's implemented
Clean pricing page with annual/monthly toggle:
// apps/nextjs/src/components/price/pricing-cards.tsx
<Switch
checked={isYearly}
onCheckedChange={toggleBilling}
/>
// Shows savings with strikethrough
{isYearly && offer?.prices?.monthly > 0 ? (
<>
<span className="line-through">${offer?.prices?.monthly}</span>
<span>${offer?.prices?.yearly / 12}</span>
</>
) : (
`$${offer?.prices?.monthly}`
)}
Benefits and limitations clearly displayed:
<ul>
{offer?.benefits.map((feature) => (
<li><Icons.Check /> {feature}</li>
))}
{offer?.limitations?.map((feature) => (
<li className="text-muted-foreground">
<Icons.Close /> {feature}
</li>
))}
</ul>
What's missing
- No "Most Popular" badge on Pro tier
- No annual savings percentage display
- No in-app upgrade prompts when hitting limits
- No feature comparison table
- No testimonials or social proof
- No FAQ about pricing
Architecture
Saasfly uses a monorepo structure with Turborepo:
| Package | Purpose |
|---|---|
apps/nextjs | Main Next.js application |
packages/api | tRPC routers |
packages/auth | NextAuth configuration |
packages/db | Prisma schema and client |
packages/stripe | Stripe SDK and webhooks |
This is a clean separation, but the feature gating logic needs to be added to packages/api.
What you need to add
Priority 1: Enforce feature limits (critical)
Uncomment and implement the limit check:
// packages/api/src/router/k8s.ts
createCluster: protectedProcedure.mutation(async ({ ctx }) => {
const customer = await getCustomer(ctx.userId);
const clusterCount = await getClusterCount(ctx.userId);
const limits = { FREE: 1, PRO: 3, BUSINESS: 10 };
const limit = limits[customer.plan || 'FREE'];
if (clusterCount >= limit) {
throw new TRPCError({
code: 'PAYMENT_REQUIRED',
message: `Limit of ${limit} clusters reached. Upgrade to create more.`,
});
}
// ... create cluster
});
Priority 2: Add custom analytics events
posthog.capture('signup_completed', { method: 'magic_link' });
posthog.capture('cluster_created', { plan: customer.plan });
posthog.capture('checkout_started', { plan: 'pro', interval: 'yearly' });
posthog.capture('checkout_completed', { plan: 'pro', mrr: 79 });
Priority 3: Trial system
Add trial support to checkout:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
subscription_data: {
trial_period_days: 14,
},
// ...
});
First steps to PLG
Saasfly has working checkout — fix the gating and add tracking:
Day 1: Fix the commented-out limit check
The feature gating code exists but is commented out. Uncomment and complete it:
// In cluster-create-button.tsx
async function onClick() {
const res = await trpc.k8s.createCluster.mutate({...});
// UNCOMMENT AND IMPLEMENT:
if (!res.success && res.code === 'LIMIT_REACHED') {
return toast({
title: "Cluster limit reached",
description: "Upgrade to Pro for more clusters.",
variant: "destructive",
});
}
}
Week 1: Enforce limits in the API
Add the server-side check that's missing:
// packages/api/src/router/k8s.ts
createCluster: protectedProcedure.mutation(async ({ ctx }) => {
const customer = await getCustomer(ctx.userId);
const clusterCount = await countClusters(ctx.userId);
const limits = { FREE: 1, PRO: 3, BUSINESS: 10 };
if (clusterCount >= limits[customer.plan || 'FREE']) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cluster limit reached'
});
}
// ... create cluster
});
Week 2: Add PostHog events
PostHog is integrated but only tracks pageviews. Add custom events:
import { usePostHog } from 'posthog-js/react';
// After signup
posthog.capture('signup_completed', { method: 'magic_link' });
// After cluster creation
posthog.capture('cluster_created', { plan: customer.plan, cluster_count: count });
// When limit is hit
posthog.capture('limit_reached', { feature: 'clusters', plan: customer.plan });
// After upgrade
posthog.capture('plan_upgraded', { from: 'FREE', to: 'PRO' });
Week 3: Add upgrade prompts
Create a reusable upgrade component:
// components/upgrade-prompt.tsx
export function UpgradePrompt({ feature, currentPlan }) {
return (
<Card>
<CardHeader>
<CardTitle>Upgrade to create more {feature}</CardTitle>
</CardHeader>
<CardContent>
<p>You're on the {currentPlan} plan.</p>
<Button onClick={() => router.push('/pricing')}>
View Plans
</Button>
</CardContent>
</Card>
);
}
Week 4: Add trial
- Modify checkout to include trial period
- Show "X days left in trial" banner
- Send email on day 10: "Your trial is ending soon"
- Track trial conversion in PostHog
When to use this template
Good fit:
- You want a clean monorepo structure
- You need full Stripe checkout working
- You prefer tRPC for type-safe APIs
Not ideal:
- You need working feature gating out of the box
- You want comprehensive analytics tracking
- You need trial optimization
Conclusion
Saasfly scores 4/10 for PLG readiness. It has the right building blocks — PostHog analytics, full Stripe integration, clean pricing UI — but the feature gating is broken. The code that would enforce plan limits is literally commented out.
The monorepo architecture is well-organized, and the self-serve checkout flow is the most complete of the open-source options we've reviewed. But shipping this to production without fixing the feature gating would mean giving away unlimited access to paying features.
Best for: Teams who want a clean starting point and will implement the limit enforcement themselves.
Frequently asked questions
Is Saasfly really a complete alternative to paid templates?
It has good bones but needs work. The Stripe integration is solid, but feature gating isn't enforced, analytics only track pageviews, and there's no trial system. Paid templates like ShipFast include these features working out of the box.
Why are the feature limits commented out?
It appears to be an incomplete implementation. The pricing data defines limits ("Up to 1 cluster per month") but the backend code that would enforce these limits is commented out with a TODO-style pattern.
Does it work with databases other than PostgreSQL?
Prisma supports multiple databases, but the schema is designed for PostgreSQL. You'd need to adjust for MySQL or SQLite.
How does the monorepo structure compare to single-repo templates?
The Turborepo structure is cleaner for larger teams — you can work on packages/stripe without touching apps/nextjs. But it adds complexity for solo developers.
This analysis was performed using PLG Skills, an open-source framework for product-led growth audits. Skills used: signup-flow-cro, feature-gating, trial-optimization, product-analytics, self-serve-motion, paywall-upgrade-cro.